Проект разделен на два репозитория
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
# Non-secret deployment configuration only.
|
||||
# Copy to .env, replace placeholders, then run: ./scripts/validate-env .env
|
||||
# Secrets are supplied at runtime by deployment/secrets/han-secrets.
|
||||
SECRETS_SOURCE=file
|
||||
APP_ENV=production-like
|
||||
RELEASE_VERSION=change-me-release
|
||||
LOG_LEVEL=INFO
|
||||
COMPOSE_PROJECT_NAME=han-chat
|
||||
|
||||
# Immutable service images built by their owning modules.
|
||||
API_BACKEND_IMAGE=han-chat-api-backend:local
|
||||
MESSAGE_SAFETY_IMAGE=han-chat-message-safety:local
|
||||
BITRIX_LOCAL_APP_IMAGE=han-chat-bitrix-local-app:local
|
||||
BITRIX_SYNC_IMAGE=han-chat-bitrix-sync:local
|
||||
KEYCLOAK_IMAGE=han-chat-keycloak:local
|
||||
SMS_SERVICE_IMAGE=han-chat-sms-service:local
|
||||
|
||||
# Managed PostgreSQL is external to Compose. Credential-bearing DSNs are secrets.
|
||||
HAN_PG_HOST=managed-pg.private.example
|
||||
HAN_PG_PORT=5433
|
||||
HAN_PG_DATABASE=han_chat
|
||||
PG_CA_HOST_PATH=/opt/han-chat/secrets/pg/ca.pem
|
||||
# PgBouncer session mode uses database-level search_path; runtime DSNs must not pass options/currentSchema.
|
||||
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
|
||||
|
||||
PUBLIC_HOST=chat.example.ru
|
||||
PUBLIC_WEB_URL=https://chat.example.ru
|
||||
PUBLIC_API_URL=https://chat.example.ru/api
|
||||
PUBLIC_AUTH_URL=https://chat.example.ru/auth
|
||||
|
||||
NGINX_HTTP_PORT=80
|
||||
NGINX_HTTPS_PORT=443
|
||||
NGINX_TLS_ENABLED=true
|
||||
NGINX_TLS_CERTIFICATE=/etc/letsencrypt/live/chat.example.ru/fullchain.pem
|
||||
NGINX_TLS_CERTIFICATE_KEY=/etc/letsencrypt/live/chat.example.ru/privkey.pem
|
||||
NGINX_HSTS_MAX_AGE=31536000
|
||||
NGINX_CLIENT_MAX_BODY_SIZE=8m
|
||||
NGINX_RATE_LIMIT_API=60r/m
|
||||
NGINX_RATE_LIMIT_AUTH=60r/m
|
||||
NGINX_RATE_LIMIT_PUBLIC=60r/m
|
||||
NGINX_RATE_LIMIT_POLLING=60r/m
|
||||
NGINX_RATE_LIMIT_DOWNLOADS=30r/m
|
||||
NGINX_RATE_LIMIT_NOTIFICATIONS_READ=120r/m
|
||||
NGINX_RATE_LIMIT_NOTIFICATIONS_ACTION=60r/m
|
||||
NGINX_RATE_LIMIT_NOTIFICATION_UPLOAD=20r/m
|
||||
NGINX_RATE_LIMIT_NOTIFICATIONS_PUBLIC=60r/m
|
||||
NGINX_RATE_LIMIT_BITRIX=120r/m
|
||||
NGINX_RATE_LIMIT_SMS_CALLBACK=120r/m
|
||||
NGINX_RATE_LIMIT_WS=30r/m
|
||||
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.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
|
||||
|
||||
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_RISK_ACCEPTED=false
|
||||
KEYCLOAK_YANDEX_CAPTCHA_ENABLED=false
|
||||
# Public site key; the server key stays in Secrets Manager when CAPTCHA is enabled.
|
||||
KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY=
|
||||
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
|
||||
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
|
||||
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
|
||||
KEYCLOAK_ADMIN=bootstrap-admin
|
||||
|
||||
REDIS_MAXMEMORY=384mb
|
||||
REDIS_EVICTION_POLICY=volatile-lru
|
||||
|
||||
# i-Digital Direct non-secret configuration.
|
||||
IDGTL_SMS_BASE_URL=https://direct.i-dgtl.ru
|
||||
IDGTL_SMS_CALLBACK_PUBLIC_URL=https://chat.example.ru/callbacks/idgtl/sms
|
||||
|
||||
BITRIX_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080
|
||||
BITRIX_API_INBOX_PATH=/internal/openlines/v1/inbox
|
||||
BITRIX_API_FORWARD_URL=http://api-backend:8000/internal/openlines/v1/inbox
|
||||
MESSAGE_SAFETY_URL=http://message-safety:8080
|
||||
MESSAGE_SAFETY_POST_TIMEOUT_SEC=5
|
||||
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC=2
|
||||
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_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
|
||||
BITRIX_CLIENT_ID=change-me-client-id
|
||||
BITRIX_CONNECTOR_ID=han_mobile_app
|
||||
BITRIX_CONNECTOR_NAME=HAN Mobile App
|
||||
BITRIX_OPEN_LINE_ID=<N>
|
||||
BITRIX_EXPECTED_DOMAIN=<ваш-портал>.bitrix24.ru
|
||||
BITRIX_PUBLIC_BASE_URL=https://chat.example.ru/bitrix
|
||||
|
||||
BITRIX_HTTP_TIMEOUT_SEC=10
|
||||
BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC=20
|
||||
|
||||
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
|
||||
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||
OTEL_SERVICE_NAME_API=api-backend
|
||||
OTEL_SERVICE_NAME_SMS_API=sms-service
|
||||
OTEL_SERVICE_NAME_SMS_WORKER=sms-worker
|
||||
SMS_METRICS_PORT=9464
|
||||
#Если есть внешний OTLP-сервис, замените (Точный формат авторизации зависит от провайдера):
|
||||
#Если внешнего OTLP-сервиса пока нет, otlp.example.invalid:4317 можно временно оставить, но Collector будет постоянно пытаться подключиться, писать предупреждения и накапливать очередь.
|
||||
OTEL_REMOTE_ENDPOINT=otlp.example.invalid:4317
|
||||
# true только для plaintext OTLP внутри доверенной приватной сети (например self-hosted SigNoz).
|
||||
OTEL_REMOTE_TLS_INSECURE=false
|
||||
# SDK отправляет все spans локальному Collector; решение о хранении принимает tail_sampling.
|
||||
OTEL_TRACES_SAMPLER=always_on
|
||||
# OTEL_QUEUE_SIZE пока документирует целевую ёмкость, конфигурация Collector закреплена в YAML.
|
||||
OTEL_QUEUE_SIZE=10000
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
*.sh text eol=lf
|
||||
scripts/validate-env text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.conf text eol=lf
|
||||
*.template text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
backups/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
.git
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
tests
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM python:3.12-slim AS builder
|
||||
WORKDIR /build
|
||||
RUN pip install --no-cache-dir --upgrade pip build
|
||||
COPY pyproject.toml ./
|
||||
COPY app ./app
|
||||
RUN python -m build --wheel
|
||||
|
||||
FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
RUN addgroup --system --gid 10001 han && adduser --system --uid 10001 --ingroup han han
|
||||
WORKDIR /app
|
||||
COPY --from=builder /build/dist/*.whl /tmp/
|
||||
RUN pip install --no-cache-dir /tmp/*.whl && rm -f /tmp/*.whl
|
||||
COPY alembic.ini ./
|
||||
COPY alembic ./alembic
|
||||
COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint
|
||||
USER 10001:10001
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["/usr/local/bin/han-container-entrypoint"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-proxy-headers"]
|
||||
@@ -0,0 +1,84 @@
|
||||
# HAN Chat API backend
|
||||
|
||||
FastAPI-сервис публичного API, internal Open Lines/settings API, WebSocket realtime,
|
||||
Message Safety orchestration, S3 lifecycle и PostgreSQL workers.
|
||||
|
||||
## Запуск
|
||||
|
||||
Python 3.12+:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv/Scripts/pip install -e ".[dev]"
|
||||
alembic upgrade head
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Миграции выполняются отдельным deployment step. Приложение не применяет DDL при старте.
|
||||
|
||||
Workers запускаются независимо:
|
||||
|
||||
```bash
|
||||
han-delivery-worker
|
||||
han-safety-worker
|
||||
han-cleanup-worker
|
||||
han-notification-expire-worker
|
||||
han-notification-draft-cleanup-worker
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Сервис читает только инфраструктурные параметры и секреты из корневого `backend/.env`.
|
||||
Локальный `.env` не коммитится. Business settings создаются миграцией в
|
||||
`han_app.app_settings`.
|
||||
|
||||
Обязательны:
|
||||
|
||||
- `APP_ENV`, `API_PORT`, `LOG_LEVEL`, `DATABASE_URL`;
|
||||
- `REDIS_URL`, `REDIS_REALTIME_URL`;
|
||||
- `KEYCLOAK_PUBLIC_URL`, `KEYCLOAK_INTERNAL_URL`, `KEYCLOAK_REALM`,
|
||||
`KEYCLOAK_AUDIENCE`;
|
||||
- `MESSAGE_SAFETY_URL`, `MESSAGE_SAFETY_SERVICE_TOKEN`,
|
||||
`MESSAGE_SAFETY_CA_FILE`, `MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2`,
|
||||
`MESSAGE_SAFETY_POST_TIMEOUT_SEC`, `MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC`,
|
||||
`MESSAGE_SAFETY_TASK_POLL_MAX_SEC`,
|
||||
`MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD`, `MESSAGE_SAFETY_CIRCUIT_OPEN_SEC`;
|
||||
- `BITRIX_LOCAL_APP_BASE_URL`, `BITRIX_LOCAL_APP_INTERNAL_TOKEN`,
|
||||
`BITRIX_API_INBOX_TOKEN`, `BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC`,
|
||||
`BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD`,
|
||||
`BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC`;
|
||||
- `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`;
|
||||
- `SELECTEL_S3_ENDPOINT_URL`, `SELECTEL_S3_BUCKET_DOCUMENTS`,
|
||||
`SELECTEL_S3_BUCKET_ATTACHMENTS`, `SELECTEL_S3_BUCKET_QUARANTINE`,
|
||||
`SELECTEL_S3_ACCESS_KEY`, `SELECTEL_S3_SECRET_KEY`;
|
||||
- `CURSOR_HMAC_SECRET` — случайный секрет не короче 32 байт;
|
||||
- `NOTIFICATIONS_TOKEN_PRODUCER_TEST` — отдельный bearer token тестового
|
||||
продюсера Notification Center; в БД синхронизируется только SHA-256 hash;
|
||||
- `OTEL_EXPORTER_OTLP_ENDPOINT` — опциональный endpoint collector.
|
||||
|
||||
Токены генерируются `openssl rand -hex 32`. S3 read-only credentials Message Safety
|
||||
не передаются этому контейнеру. В production подключение PostgreSQL должно использовать
|
||||
TLS. Target `MESSAGE_SAFETY_URL=https://processing.internal:8443`; certificate
|
||||
проверяется по internal CA, plaintext HTTP запрещён. Текущий Docker hostname
|
||||
`message-safety` относится только к legacy stub до cutover.
|
||||
|
||||
Smoke-сценарий `producer_test`: отправить `POST
|
||||
/internal/notifications/v1/notifications` с `Authorization: Bearer
|
||||
$NOTIFICATIONS_TOKEN_PRODUCER_TEST`, `source=producer_test` и уникальным
|
||||
`external_id`; повтор того же тела вернёт `200`. Затем передать ту же пару
|
||||
`source`/`external_id` в `POST /internal/notifications/v1/notifications/cancel`
|
||||
с `close_reason=cancelled`; повторная отмена также вернёт `200`.
|
||||
|
||||
## Проверки
|
||||
|
||||
```bash
|
||||
ruff check .
|
||||
ruff format --check .
|
||||
mypy app
|
||||
pytest
|
||||
```
|
||||
|
||||
`/health/live` проверяет процесс. `/health/ready` проверяет критические
|
||||
зависимости read API. Remote Message Safety не выключает чтение/общую readiness:
|
||||
send endpoint отдельно проверяет требуемую capability и fail-closed возвращает
|
||||
`503`, если ВМ2 недоступна.
|
||||
@@ -0,0 +1,37 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,49 @@
|
||||
import asyncio
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
from app.db import Base
|
||||
from app.postgres import create_postgres_engine
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name:
|
||||
fileConfig(config.config_file_name)
|
||||
database_url = os.environ["DATABASE_URL"]
|
||||
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def do_run_migrations(connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
include_schemas=True,
|
||||
version_table_schema="han_app",
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
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()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
include_schemas=True,
|
||||
version_table_schema="han_app",
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
else:
|
||||
asyncio.run(run_async_migrations())
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Initial han_app schema, seed and CRM sync triggers.
|
||||
|
||||
Revision ID: 0001_initial
|
||||
Revises:
|
||||
Create Date: 2026-07-10
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from app.db import Base
|
||||
|
||||
revision: str = "0001_initial"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
SEED = {
|
||||
"auth.phone.enabled": ("true", "boolean", True),
|
||||
"auth.password.enabled": ("false", "boolean", True),
|
||||
"otp.phone.max_send_attempts_per_24h": ("3", "integer", False),
|
||||
"otp.phone.min_seconds_between_attempts": ("30", "integer", False),
|
||||
"operator.call.phone": ("+74999591007", "string", True),
|
||||
"consent.personal_data.required": ("true", "boolean", True),
|
||||
"consent.personal_data.document_url": (
|
||||
"https://www.han0107.ru/privacy/persdata-agree-mobile",
|
||||
"string",
|
||||
True,
|
||||
),
|
||||
"consent.personal_data.version": ("2026-06-10", "string", True),
|
||||
"consent.privacy_policy.document_url": (
|
||||
"https://www.han0107.ru/privacy",
|
||||
"string",
|
||||
True,
|
||||
),
|
||||
"consent.user_agreement.required": ("true", "boolean", True),
|
||||
"consent.user_agreement.document_url": (
|
||||
"https://www.han0107.ru/user-agreement",
|
||||
"string",
|
||||
True,
|
||||
),
|
||||
"consent.user_agreement.version": ("2026-06-10", "string", True),
|
||||
"consent.marketing.required": ("false", "boolean", True),
|
||||
"consent.marketing.document_url": (
|
||||
"https://www.han0107.ru/privacy/ads-agree",
|
||||
"string",
|
||||
True,
|
||||
),
|
||||
"consent.marketing.version": ("2026-06-10", "string", True),
|
||||
"chat.attachments.allowed_extensions": (
|
||||
"jpg,jpeg,png,webp,heic,heif,pdf",
|
||||
"string_list",
|
||||
True,
|
||||
),
|
||||
"chat.attachments.allowed_mime_types": (
|
||||
"image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf",
|
||||
"string_list",
|
||||
True,
|
||||
),
|
||||
"chat.attachments.disallowed_extensions": ("svg,doc,docx,xls,xlsx,csv", "string_list", False),
|
||||
"chat.attachments.max_size_mb": ("5", "integer", True),
|
||||
"chat.attachments.storage": ("selectel_s3", "string", False),
|
||||
"chat.attachments.upload_mode": ("presigned_put", "string", False),
|
||||
"chat.attachments.safety_scan_required": ("true", "boolean", False),
|
||||
"chat.attachments.presigned_upload_ttl_seconds": ("600", "integer", False),
|
||||
"rate_limit.message_send.per_user": ("30/minute", "string", False),
|
||||
"rate_limit.message_send.per_dialog": ("20/minute", "string", False),
|
||||
"rate_limit.download_url.per_user": ("60/hour", "string", False),
|
||||
"rate_limit.public_endpoints.per_ip": ("60/minute", "string", False),
|
||||
"rate_limit.login.per_ip": ("10/minute", "string", False),
|
||||
"ux.session.idle_timeout_minutes": ("30", "integer", True),
|
||||
"security.cors.allowed_origins": ("https://tohin.ru", "string_list", False),
|
||||
"security.public_cache.max_age_seconds": ("3600", "integer", False),
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto")
|
||||
op.execute("CREATE SCHEMA IF NOT EXISTS han_app")
|
||||
Base.metadata.create_all(bind=bind, checkfirst=True)
|
||||
op.execute(
|
||||
"""
|
||||
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')
|
||||
"""
|
||||
)
|
||||
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'
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = han_app, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
v_entity_id uuid;
|
||||
v_task_type text;
|
||||
v_dedup text;
|
||||
BEGIN
|
||||
IF current_setting('han.sync_suppress', true) = 'true' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF TG_TABLE_NAME = 'user_identities' THEN
|
||||
v_entity_id := NEW.id;
|
||||
v_task_type := CASE WHEN TG_OP = 'INSERT'
|
||||
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
||||
ELSE
|
||||
v_entity_id := NEW.user_id;
|
||||
v_task_type := CASE WHEN TG_OP = 'INSERT'
|
||||
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
||||
END IF;
|
||||
v_dedup := v_task_type || ':' || v_entity_id::text || ':' ||
|
||||
encode(public.digest(row_to_json(NEW)::text, 'sha256'), 'hex');
|
||||
INSERT INTO han_app.sync_queue
|
||||
(id, task_type, entity_type, entity_id, dedup_key, payload_json,
|
||||
status, attempt_count, next_attempt_at, created_at, updated_at)
|
||||
VALUES
|
||||
(gen_random_uuid(), v_task_type, 'contact', v_entity_id, v_dedup,
|
||||
jsonb_build_object('entity_id', v_entity_id), 'pending', 0,
|
||||
now(), now(), now())
|
||||
ON CONFLICT (dedup_key) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
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()
|
||||
"""
|
||||
)
|
||||
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 key, (value, value_type, public) in SEED.items():
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO han_app.app_settings
|
||||
(setting_key, setting_value, value_type, is_public, record_status, updated_at)
|
||||
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": key, "value": value, "value_type": value_type, "public": public},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("Initial data migration is forward-only")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Qualify pgcrypto digest in the contact sync trigger.
|
||||
|
||||
Revision ID: 0002_pgcrypto_digest
|
||||
Revises: 0001_initial
|
||||
Create Date: 2026-07-16
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0002_pgcrypto_digest"
|
||||
down_revision: str | None = "0001_initial"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = han_app, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
v_entity_id uuid;
|
||||
v_task_type text;
|
||||
v_dedup text;
|
||||
BEGIN
|
||||
IF current_setting('han.sync_suppress', true) = 'true' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF TG_TABLE_NAME = 'user_identities' THEN
|
||||
v_entity_id := NEW.id;
|
||||
v_task_type := CASE WHEN TG_OP = 'INSERT'
|
||||
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
||||
ELSE
|
||||
v_entity_id := NEW.user_id;
|
||||
v_task_type := CASE WHEN TG_OP = 'INSERT'
|
||||
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
||||
END IF;
|
||||
v_dedup := v_task_type || ':' || v_entity_id::text || ':' ||
|
||||
encode(public.digest(row_to_json(NEW)::text, 'sha256'), 'hex');
|
||||
INSERT INTO han_app.sync_queue
|
||||
(id, task_type, entity_type, entity_id, dedup_key, payload_json,
|
||||
status, attempt_count, next_attempt_at, created_at, updated_at)
|
||||
VALUES
|
||||
(gen_random_uuid(), v_task_type, 'contact', v_entity_id, v_dedup,
|
||||
jsonb_build_object('entity_id', v_entity_id), 'pending', 0,
|
||||
now(), now(), now())
|
||||
ON CONFLICT (dedup_key) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("Contact sync trigger migration is forward-only")
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
"""Store consent device snapshots and remove audit IP.
|
||||
|
||||
Revision ID: 0003_consent_audit
|
||||
Revises: 0002_pgcrypto_digest
|
||||
Create Date: 2026-07-16
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
revision: str = "0003_consent_audit"
|
||||
down_revision: str | None = "0002_pgcrypto_digest"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.user_consents
|
||||
ADD COLUMN IF NOT EXISTS device_json jsonb NOT NULL DEFAULT '{}'::jsonb
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.audit_events
|
||||
DROP COLUMN IF EXISTS ip
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("Consent and audit context migration is forward-only")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Store raw device identifiers and add OTP verification limit.
|
||||
|
||||
Revision ID: 0004_device_otp
|
||||
Revises: 0003_consent_audit
|
||||
Create Date: 2026-07-21
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004_device_otp"
|
||||
down_revision: str | None = "0003_consent_audit"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.ux_sessions
|
||||
ADD COLUMN IF NOT EXISTS device_id varchar(255)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO han_app.app_settings
|
||||
(setting_key, setting_value, value_type, is_public, description,
|
||||
record_status, updated_at)
|
||||
VALUES
|
||||
('otp.phone.max_verify_attempts', '5', 'integer', false,
|
||||
'Maximum failed verification attempts for one OTP challenge',
|
||||
'A', now())
|
||||
ON CONFLICT (setting_key) DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("Device and OTP limits migration is forward-only")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Seed runtime OTP settings.
|
||||
|
||||
Revision ID: 0005_otp_settings
|
||||
Revises: 0004_device_otp
|
||||
Create Date: 2026-07-22
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0005_otp_settings"
|
||||
down_revision: str | None = "0004_device_otp"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO han_app.app_settings
|
||||
(setting_key, setting_value, value_type, is_public, description,
|
||||
record_status, updated_at)
|
||||
VALUES
|
||||
('otp.phone.code_length', '6', 'integer', false,
|
||||
'Length of the numeric phone OTP', 'A', now()),
|
||||
('otp.phone.ttl_seconds', '60', 'integer', false,
|
||||
'Phone OTP lifetime from durable order time', 'A', now()),
|
||||
('otp.phone.sms_order_timeout_ms', '3000', 'integer', false,
|
||||
'Keycloak timeout for durable SMS order creation', 'A', now())
|
||||
ON CONFLICT (setting_key) DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("OTP runtime settings migration is forward-only")
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
"""Seed consent.privacy_policy.document_url for personal_data consent UI.
|
||||
|
||||
Revision ID: 0006_privacy_policy
|
||||
Revises: 0005_otp_settings
|
||||
Create Date: 2026-07-23
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0006_privacy_policy"
|
||||
down_revision: str | None = "0005_otp_settings"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO han_app.app_settings
|
||||
(setting_key, setting_value, value_type, is_public, description,
|
||||
record_status, updated_at)
|
||||
VALUES
|
||||
('consent.privacy_policy.document_url',
|
||||
'https://www.han0107.ru/privacy', 'string', true,
|
||||
'Privacy policy URL shown next to personal_data consent', 'A', now())
|
||||
ON CONFLICT (setting_key) DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("Privacy policy consent URL migration is forward-only")
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
"""Seed consent.marketing.document_url for marketing consent UI link.
|
||||
|
||||
Revision ID: 0007_marketing_doc
|
||||
Revises: 0006_privacy_policy
|
||||
Create Date: 2026-07-23
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0007_marketing_doc"
|
||||
down_revision: str | None = "0006_privacy_policy"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO han_app.app_settings
|
||||
(setting_key, setting_value, value_type, is_public, description,
|
||||
record_status, updated_at)
|
||||
VALUES
|
||||
('consent.marketing.document_url',
|
||||
'https://www.han0107.ru/privacy/ads-agree', 'string', true,
|
||||
'Marketing communications consent document URL', 'A', now())
|
||||
ON CONFLICT (setting_key) DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("Marketing consent document URL migration is forward-only")
|
||||
@@ -0,0 +1,651 @@
|
||||
"""Notification Center v1 schema, catalog and settings.
|
||||
|
||||
Revision ID: 0008_notifications_v1
|
||||
Revises: 0007_marketing_doc
|
||||
Create Date: 2026-07-27
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0008_notifications_v1"
|
||||
down_revision: str | None = "0007_marketing_doc"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
COMMON = """
|
||||
record_status varchar(1) NOT NULL DEFAULT 'A',
|
||||
status_changed_at timestamptz NULL,
|
||||
status_change_reason varchar(255) NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updater_user_id uuid NULL
|
||||
"""
|
||||
|
||||
|
||||
def _execute_batch(sql: str) -> None:
|
||||
"""Execute one statement at a time for the asyncpg prepared-statement dialect."""
|
||||
statement: list[str] = []
|
||||
in_dollar_quote = False
|
||||
index = 0
|
||||
while index < len(sql):
|
||||
if sql[index : index + 2] == "$$":
|
||||
in_dollar_quote = not in_dollar_quote
|
||||
statement.append("$$")
|
||||
index += 2
|
||||
continue
|
||||
character = sql[index]
|
||||
if character == ";" and not in_dollar_quote:
|
||||
value = "".join(statement).strip()
|
||||
if value:
|
||||
op.execute(value)
|
||||
statement.clear()
|
||||
else:
|
||||
statement.append(character)
|
||||
index += 1
|
||||
value = "".join(statement).strip()
|
||||
if value:
|
||||
op.execute(value)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
_execute_batch(
|
||||
f"""
|
||||
CREATE TABLE han_app.notification_cta_actions (
|
||||
id uuid PRIMARY KEY, code varchar(32) NOT NULL UNIQUE,
|
||||
description varchar(255) NOT NULL, requires_auth boolean NOT NULL,
|
||||
required_instance_fields varchar(64)[] NOT NULL DEFAULT '{{}}', {COMMON}
|
||||
);
|
||||
CREATE TABLE han_app.notification_buttons (
|
||||
id uuid PRIMARY KEY, code varchar(32) NOT NULL UNIQUE,
|
||||
label varchar(64) NOT NULL, sets_hidden boolean NOT NULL DEFAULT false,
|
||||
applies_hidden_ttl boolean NOT NULL DEFAULT false,
|
||||
close_reason varchar(32), submits_documents boolean NOT NULL DEFAULT false,
|
||||
{COMMON},
|
||||
CHECK (NOT applies_hidden_ttl OR sets_hidden),
|
||||
CHECK (NOT submits_documents OR close_reason IS NOT NULL)
|
||||
);
|
||||
CREATE TABLE han_app.notification_color_tokens (
|
||||
id uuid PRIMARY KEY, code varchar(32) NOT NULL UNIQUE,
|
||||
description varchar(255) NOT NULL, sort_order smallint NOT NULL, {COMMON}
|
||||
);
|
||||
CREATE TABLE han_app.notification_types (
|
||||
id uuid PRIMARY KEY, code varchar(32) NOT NULL UNIQUE,
|
||||
contour varchar(1) NOT NULL CHECK (contour IN ('G','P')),
|
||||
priority smallint NOT NULL, countable boolean NOT NULL,
|
||||
label varchar(64) NOT NULL,
|
||||
color_token varchar(32) NOT NULL REFERENCES han_app.notification_color_tokens(code)
|
||||
ON DELETE RESTRICT,
|
||||
icon_code varchar(32), cta_text varchar(64) NOT NULL,
|
||||
cta_action varchar(32) NOT NULL REFERENCES han_app.notification_cta_actions(code)
|
||||
ON DELETE RESTRICT,
|
||||
cta_sets_hidden boolean NOT NULL DEFAULT false,
|
||||
cta_close_reason varchar(32),
|
||||
button_primary_code varchar(32) REFERENCES han_app.notification_buttons(code)
|
||||
ON DELETE RESTRICT,
|
||||
button_secondary_code varchar(32) REFERENCES han_app.notification_buttons(code)
|
||||
ON DELETE RESTRICT,
|
||||
hidden_ttl_days smallint,
|
||||
documents_allowed boolean NOT NULL DEFAULT false,
|
||||
hide_on_document_download boolean NOT NULL DEFAULT false,
|
||||
required_detail_blocks varchar(64)[] NOT NULL DEFAULT '{{}}',
|
||||
{COMMON},
|
||||
CHECK (contour <> 'G' OR countable = false),
|
||||
CHECK (contour <> 'G' OR cta_action <> 'open_detail'),
|
||||
CHECK (cta_action <> 'open_detail' OR
|
||||
(cta_sets_hidden = false AND cta_close_reason IS NULL)),
|
||||
CHECK (cta_action = 'open_detail' OR
|
||||
(documents_allowed = false AND hide_on_document_download = false
|
||||
AND required_detail_blocks = '{{}}')),
|
||||
CHECK (NOT hide_on_document_download OR documents_allowed),
|
||||
CHECK (cta_action <> 'open_detail' OR button_primary_code IS NOT NULL),
|
||||
CHECK (cta_action = 'open_detail' OR
|
||||
(button_primary_code IS NULL AND button_secondary_code IS NULL)),
|
||||
CHECK (button_secondary_code IS NULL OR button_primary_code IS NOT NULL),
|
||||
CHECK (button_secondary_code IS NULL OR
|
||||
button_secondary_code <> button_primary_code)
|
||||
);
|
||||
CREATE TABLE han_app.notification_sources (
|
||||
id uuid PRIMARY KEY, code varchar(32) NOT NULL UNIQUE,
|
||||
description text, token_hash varchar(128) NOT NULL UNIQUE,
|
||||
token_rotated_at timestamptz, {COMMON}
|
||||
);
|
||||
CREATE TABLE han_app.notifications (
|
||||
id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES han_app.user_identities(id) ON DELETE RESTRICT,
|
||||
notification_type varchar(32) NOT NULL
|
||||
REFERENCES han_app.notification_types(code) ON DELETE RESTRICT,
|
||||
source varchar(32) NOT NULL
|
||||
REFERENCES han_app.notification_sources(code) ON DELETE RESTRICT,
|
||||
external_id varchar(128) NOT NULL,
|
||||
request_fingerprint varchar(64) NOT NULL,
|
||||
notification_datetime timestamptz NOT NULL,
|
||||
header varchar(255) NOT NULL, text varchar(1024),
|
||||
priority_override smallint, date_expired timestamptz,
|
||||
price numeric(12,2), old_price numeric(12,2), payment_url text,
|
||||
details jsonb, details_schema_version smallint NOT NULL DEFAULT 1,
|
||||
chat_message_text varchar(1024),
|
||||
lifecycle_status varchar(16) NOT NULL DEFAULT 'active'
|
||||
CHECK (lifecycle_status IN ('active','closed')),
|
||||
visibility varchar(16) NOT NULL DEFAULT 'visible'
|
||||
CHECK (visibility IN ('visible','hidden')),
|
||||
is_read boolean NOT NULL DEFAULT false,
|
||||
close_reason varchar(32) CHECK (close_reason IS NULL OR close_reason IN
|
||||
('user_done','docs_submitted','offer_accepted','paid','expired','cancelled')),
|
||||
closed_at timestamptz, {COMMON},
|
||||
CONSTRAINT uq_notifications_source_key UNIQUE (source, external_id),
|
||||
CHECK (old_price IS NULL OR price IS NOT NULL),
|
||||
CHECK (lifecycle_status <> 'closed' OR
|
||||
(close_reason IS NOT NULL AND closed_at IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX ix_notifications_user_active
|
||||
ON han_app.notifications
|
||||
(user_id, lifecycle_status, visibility, notification_datetime DESC, id DESC)
|
||||
WHERE record_status='A';
|
||||
CREATE INDEX ix_notifications_expire ON han_app.notifications(date_expired)
|
||||
WHERE record_status='A' AND lifecycle_status='active'
|
||||
AND date_expired IS NOT NULL;
|
||||
CREATE TABLE han_app.guest_notifications (
|
||||
id uuid PRIMARY KEY,
|
||||
notification_type varchar(32) NOT NULL
|
||||
REFERENCES han_app.notification_types(code) ON DELETE RESTRICT,
|
||||
notification_datetime timestamptz NOT NULL,
|
||||
header varchar(255) NOT NULL, text varchar(1024),
|
||||
priority_override smallint, date_expired timestamptz,
|
||||
price numeric(12,2), old_price numeric(12,2),
|
||||
instruction_url text, chat_message_text varchar(1024),
|
||||
lifecycle_status varchar(16) NOT NULL DEFAULT 'active'
|
||||
CHECK (lifecycle_status IN ('active','closed')),
|
||||
closed_at timestamptz, {COMMON},
|
||||
CHECK (old_price IS NULL OR price IS NOT NULL),
|
||||
CHECK (instruction_url IS NULL OR instruction_url LIKE 'https://%')
|
||||
);
|
||||
CREATE INDEX ix_guest_notifications_active
|
||||
ON han_app.guest_notifications(lifecycle_status, notification_datetime DESC, id DESC)
|
||||
WHERE record_status='A';
|
||||
CREATE INDEX ix_guest_notifications_expire
|
||||
ON han_app.guest_notifications(date_expired)
|
||||
WHERE record_status='A' AND lifecycle_status='active'
|
||||
AND date_expired IS NOT NULL;
|
||||
CREATE TABLE han_app.notification_documents (
|
||||
id uuid PRIMARY KEY,
|
||||
notification_id uuid NOT NULL
|
||||
REFERENCES han_app.notifications(id) ON DELETE RESTRICT,
|
||||
document_id uuid NOT NULL REFERENCES han_app.documents(id) ON DELETE RESTRICT,
|
||||
sort_order smallint NOT NULL DEFAULT 0,
|
||||
download_url_issued_at timestamptz, {COMMON},
|
||||
UNIQUE (notification_id, document_id)
|
||||
);
|
||||
CREATE TABLE han_app.client_upload_drafts (
|
||||
id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES han_app.user_identities(id) ON DELETE RESTRICT,
|
||||
context_type varchar(32) NOT NULL CHECK (context_type IN ('notification')),
|
||||
context_id uuid NOT NULL,
|
||||
original_file_name varchar(255) NOT NULL,
|
||||
safe_file_name varchar(255) NOT NULL,
|
||||
mime_type varchar(128) NOT NULL,
|
||||
size_bytes bigint NOT NULL CHECK (size_bytes > 0),
|
||||
checksum_sha256 char(64),
|
||||
scan_status varchar(16) NOT NULL DEFAULT 'pending'
|
||||
CHECK (scan_status IN ('pending','clean','infected','failed')),
|
||||
storage_bucket varchar(255) NOT NULL,
|
||||
object_key varchar(1024) NOT NULL,
|
||||
quarantine_object_key varchar(1024),
|
||||
upload_expires_at timestamptz, completed_at timestamptz,
|
||||
state varchar(16) NOT NULL DEFAULT 'draft'
|
||||
CHECK (state IN ('draft','submitted','discarded')),
|
||||
submission_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX ix_client_upload_drafts_context
|
||||
ON han_app.client_upload_drafts(user_id, context_type, context_id)
|
||||
WHERE state='draft';
|
||||
CREATE INDEX ix_client_upload_drafts_scan
|
||||
ON han_app.client_upload_drafts(scan_status, updated_at);
|
||||
CREATE INDEX ix_client_upload_drafts_created
|
||||
ON han_app.client_upload_drafts(created_at);
|
||||
CREATE TABLE han_app.client_documents (
|
||||
id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES han_app.user_identities(id) ON DELETE RESTRICT,
|
||||
context_type varchar(32) NOT NULL, context_id uuid NOT NULL,
|
||||
submission_id uuid NOT NULL, source_draft_id uuid NOT NULL UNIQUE,
|
||||
original_file_name varchar(255) NOT NULL, safe_file_name varchar(255) NOT NULL,
|
||||
mime_type varchar(128) NOT NULL, size_bytes bigint NOT NULL,
|
||||
checksum_sha256 char(64) NOT NULL, storage_bucket varchar(255) NOT NULL,
|
||||
object_key varchar(1024) NOT NULL, submitted_at timestamptz NOT NULL,
|
||||
{COMMON}, UNIQUE (storage_bucket, object_key)
|
||||
);
|
||||
CREATE INDEX ix_client_documents_context
|
||||
ON han_app.client_documents(context_type, context_id);
|
||||
CREATE INDEX ix_client_documents_user_submitted
|
||||
ON han_app.client_documents(user_id, submitted_at DESC);
|
||||
"""
|
||||
)
|
||||
|
||||
_execute_batch(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION han_app.validate_guest_notification()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER
|
||||
SET search_path = han_app, pg_temp AS $$
|
||||
DECLARE v_contour varchar(1); v_action varchar(32); v_required varchar(64)[];
|
||||
BEGIN
|
||||
SELECT nt.contour, nt.cta_action, ca.required_instance_fields
|
||||
INTO v_contour, v_action, v_required
|
||||
FROM han_app.notification_types nt
|
||||
JOIN han_app.notification_cta_actions ca ON ca.code=nt.cta_action
|
||||
WHERE nt.code=NEW.notification_type AND nt.record_status='A';
|
||||
IF v_contour IS DISTINCT FROM 'G' THEN
|
||||
RAISE EXCEPTION 'notification type must use guest contour';
|
||||
END IF;
|
||||
IF ('instruction_url'=ANY(v_required)) <> (NEW.instruction_url IS NOT NULL)
|
||||
OR ('chat_message_text'=ANY(v_required)) <>
|
||||
(NEW.chat_message_text IS NOT NULL) THEN
|
||||
RAISE EXCEPTION 'guest notification fields do not match CTA';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER trg_validate_guest_notification
|
||||
BEFORE INSERT OR UPDATE ON han_app.guest_notifications
|
||||
FOR EACH ROW EXECUTE FUNCTION han_app.validate_guest_notification();
|
||||
|
||||
CREATE OR REPLACE FUNCTION han_app.enqueue_client_document_sync()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER
|
||||
SET search_path = han_app, pg_temp AS $$
|
||||
BEGIN
|
||||
IF current_setting('han.sync_suppress', true) = 'true' THEN RETURN NEW; END IF;
|
||||
INSERT INTO han_app.sync_queue
|
||||
(id, task_type, entity_type, entity_id, dedup_key, payload_json,
|
||||
status, attempt_count, next_attempt_at, created_at, updated_at)
|
||||
VALUES (
|
||||
gen_random_uuid(), 'document.client_uploaded', 'client_document', NEW.id,
|
||||
'document.client_uploaded:' || NEW.id::text,
|
||||
jsonb_build_object(
|
||||
'client_document_id', NEW.id, 'user_id', NEW.user_id,
|
||||
'context_type', NEW.context_type, 'context_id', NEW.context_id,
|
||||
'submission_id', NEW.submission_id, 'storage_bucket', NEW.storage_bucket,
|
||||
'object_key', NEW.object_key, 'original_file_name', NEW.original_file_name,
|
||||
'mime_type', NEW.mime_type, 'size_bytes', NEW.size_bytes,
|
||||
'checksum_sha256', NEW.checksum_sha256),
|
||||
'pending', 0, now(), now(), now())
|
||||
ON CONFLICT (dedup_key) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER trg_client_document_sync
|
||||
AFTER INSERT ON han_app.client_documents
|
||||
FOR EACH ROW WHEN (NEW.record_status='A')
|
||||
EXECUTE FUNCTION han_app.enqueue_client_document_sync();
|
||||
"""
|
||||
)
|
||||
|
||||
_seed_catalog(bind)
|
||||
_seed_settings(bind)
|
||||
|
||||
|
||||
def _seed_catalog(bind: sa.Connection) -> None:
|
||||
# asyncpg rejects multiple SQL commands in one prepared statement.
|
||||
_execute_batch(
|
||||
"""
|
||||
INSERT INTO han_app.notification_cta_actions
|
||||
(id,code,description,requires_auth,required_instance_fields)
|
||||
VALUES
|
||||
(gen_random_uuid(),'open_detail','Open notification detail',true,ARRAY['details']),
|
||||
(gen_random_uuid(),'open_payment_url','Open payment URL',true,ARRAY['payment_url']),
|
||||
(gen_random_uuid(),'send_chat_message','Send prepared chat message',true,
|
||||
ARRAY['chat_message_text']),
|
||||
(gen_random_uuid(),'start_auth','Start authentication',false,ARRAY[]::varchar[]),
|
||||
(gen_random_uuid(),'install_app_prompt','Install application or open instruction',
|
||||
false,ARRAY['instruction_url']);
|
||||
INSERT INTO han_app.notification_buttons
|
||||
(id,code,label,sets_hidden,applies_hidden_ttl,close_reason,submits_documents)
|
||||
VALUES
|
||||
(gen_random_uuid(),'done','Готово',false,false,'user_done',false),
|
||||
(gen_random_uuid(),'later','Сделаю позже',false,false,NULL,false),
|
||||
(gen_random_uuid(),'gotit','Понятно',true,true,NULL,false),
|
||||
(gen_random_uuid(),'send_docs','Отправить документы',false,false,
|
||||
'docs_submitted',true);
|
||||
INSERT INTO han_app.notification_color_tokens
|
||||
(id,code,description,sort_order)
|
||||
VALUES
|
||||
(gen_random_uuid(),'critical','Requires immediate attention',1),
|
||||
(gen_random_uuid(),'warning','Waiting for a client action',2),
|
||||
(gen_random_uuid(),'success','Successful result',3),
|
||||
(gen_random_uuid(),'info','Informational notification',4),
|
||||
(gen_random_uuid(),'promo','Marketing offer',5),
|
||||
(gen_random_uuid(),'neutral','Neutral interface hint',6);
|
||||
"""
|
||||
)
|
||||
types = [
|
||||
(
|
||||
"authorize",
|
||||
"G",
|
||||
1,
|
||||
False,
|
||||
"Гостевой режим",
|
||||
"neutral",
|
||||
"login",
|
||||
"Войти →",
|
||||
"start_auth",
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
False,
|
||||
False,
|
||||
[],
|
||||
),
|
||||
(
|
||||
"install_app",
|
||||
"G",
|
||||
2,
|
||||
False,
|
||||
"Приложение",
|
||||
"neutral",
|
||||
"install",
|
||||
"Установить →",
|
||||
"install_app_prompt",
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
False,
|
||||
False,
|
||||
[],
|
||||
),
|
||||
(
|
||||
"promo_global",
|
||||
"G",
|
||||
3,
|
||||
False,
|
||||
"Акция",
|
||||
"promo",
|
||||
"promo",
|
||||
"Узнать подробнее →",
|
||||
"send_chat_message",
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
False,
|
||||
False,
|
||||
[],
|
||||
),
|
||||
(
|
||||
"ads_global",
|
||||
"G",
|
||||
4,
|
||||
False,
|
||||
"Предложение",
|
||||
"promo",
|
||||
"offer",
|
||||
"Узнать подробнее →",
|
||||
"send_chat_message",
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
False,
|
||||
False,
|
||||
[],
|
||||
),
|
||||
(
|
||||
"urgent",
|
||||
"P",
|
||||
1,
|
||||
True,
|
||||
"Срочно",
|
||||
"critical",
|
||||
"urgent",
|
||||
"Подробнее →",
|
||||
"open_detail",
|
||||
False,
|
||||
None,
|
||||
"done",
|
||||
"later",
|
||||
False,
|
||||
False,
|
||||
[],
|
||||
),
|
||||
(
|
||||
"payment_pending",
|
||||
"P",
|
||||
2,
|
||||
True,
|
||||
"Оплата",
|
||||
"warning",
|
||||
"payment",
|
||||
"Оплатить →",
|
||||
"open_payment_url",
|
||||
False,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
False,
|
||||
False,
|
||||
[],
|
||||
),
|
||||
(
|
||||
"docs_required",
|
||||
"P",
|
||||
2,
|
||||
True,
|
||||
"Требуются документы",
|
||||
"warning",
|
||||
"upload",
|
||||
"Загрузить документы →",
|
||||
"open_detail",
|
||||
False,
|
||||
None,
|
||||
"send_docs",
|
||||
"later",
|
||||
False,
|
||||
False,
|
||||
[],
|
||||
),
|
||||
(
|
||||
"docs_ready",
|
||||
"P",
|
||||
3,
|
||||
True,
|
||||
"Документы готовы",
|
||||
"success",
|
||||
"download",
|
||||
"Скачать →",
|
||||
"open_detail",
|
||||
False,
|
||||
None,
|
||||
"gotit",
|
||||
None,
|
||||
True,
|
||||
True,
|
||||
["documents"],
|
||||
),
|
||||
(
|
||||
"status_changed",
|
||||
"P",
|
||||
3,
|
||||
True,
|
||||
"Статус",
|
||||
"info",
|
||||
"status",
|
||||
"Подробнее →",
|
||||
"open_detail",
|
||||
False,
|
||||
None,
|
||||
"gotit",
|
||||
None,
|
||||
False,
|
||||
False,
|
||||
[],
|
||||
),
|
||||
(
|
||||
"reminder",
|
||||
"P",
|
||||
3,
|
||||
True,
|
||||
"Напоминание",
|
||||
"info",
|
||||
"reminder",
|
||||
"Подробнее →",
|
||||
"open_detail",
|
||||
False,
|
||||
None,
|
||||
"done",
|
||||
"later",
|
||||
False,
|
||||
False,
|
||||
[],
|
||||
),
|
||||
(
|
||||
"news",
|
||||
"P",
|
||||
4,
|
||||
True,
|
||||
"Новость",
|
||||
"info",
|
||||
"news",
|
||||
"Подробнее →",
|
||||
"open_detail",
|
||||
False,
|
||||
None,
|
||||
"gotit",
|
||||
None,
|
||||
False,
|
||||
False,
|
||||
[],
|
||||
),
|
||||
(
|
||||
"promo_personal",
|
||||
"P",
|
||||
5,
|
||||
True,
|
||||
"Акция",
|
||||
"promo",
|
||||
"promo",
|
||||
"Узнать подробнее →",
|
||||
"send_chat_message",
|
||||
True,
|
||||
"offer_accepted",
|
||||
None,
|
||||
None,
|
||||
False,
|
||||
False,
|
||||
[],
|
||||
),
|
||||
(
|
||||
"ads_personal",
|
||||
"P",
|
||||
5,
|
||||
True,
|
||||
"Предложение",
|
||||
"promo",
|
||||
"offer",
|
||||
"Узнать подробнее →",
|
||||
"send_chat_message",
|
||||
True,
|
||||
"offer_accepted",
|
||||
None,
|
||||
None,
|
||||
False,
|
||||
False,
|
||||
[],
|
||||
),
|
||||
]
|
||||
statement = sa.text(
|
||||
"""
|
||||
INSERT INTO han_app.notification_types
|
||||
(id,code,contour,priority,countable,label,color_token,icon_code,cta_text,cta_action,
|
||||
cta_sets_hidden,cta_close_reason,button_primary_code,button_secondary_code,
|
||||
documents_allowed,hide_on_document_download,required_detail_blocks)
|
||||
VALUES
|
||||
(gen_random_uuid(),:code,:contour,:priority,:countable,:label,:color,:icon,:cta_text,
|
||||
:action,:sets_hidden,:close_reason,:primary,:secondary,:documents_allowed,
|
||||
:hide_download,:required)
|
||||
"""
|
||||
)
|
||||
for row in types:
|
||||
bind.execute(
|
||||
statement,
|
||||
dict(
|
||||
zip(
|
||||
(
|
||||
"code",
|
||||
"contour",
|
||||
"priority",
|
||||
"countable",
|
||||
"label",
|
||||
"color",
|
||||
"icon",
|
||||
"cta_text",
|
||||
"action",
|
||||
"sets_hidden",
|
||||
"close_reason",
|
||||
"primary",
|
||||
"secondary",
|
||||
"documents_allowed",
|
||||
"hide_download",
|
||||
"required",
|
||||
),
|
||||
row,
|
||||
strict=True,
|
||||
)
|
||||
),
|
||||
)
|
||||
token = os.getenv("NOTIFICATIONS_TOKEN_PRODUCER_TEST", "")
|
||||
token_hash = (
|
||||
hashlib.sha256(token.encode()).hexdigest()
|
||||
if token
|
||||
else hashlib.sha256(b"disabled:producer_test").hexdigest()
|
||||
)
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO han_app.notification_sources
|
||||
(id,code,description,token_hash,token_rotated_at)
|
||||
VALUES (gen_random_uuid(),'producer_test','Notification Center smoke producer',
|
||||
:token_hash,now())
|
||||
"""
|
||||
),
|
||||
{"token_hash": token_hash},
|
||||
)
|
||||
|
||||
|
||||
def _seed_settings(bind: sa.Connection) -> None:
|
||||
settings = [
|
||||
("notification.home.max_items", "7", "integer", False),
|
||||
("notification.center.max_items", "15", "integer", False),
|
||||
("notification.carousel.autoplay_enabled", "false", "boolean", True),
|
||||
("notification.carousel.autoplay_interval_ms", "5000", "integer", True),
|
||||
("notification.hidden.default_ttl_days", "3", "integer", False),
|
||||
("notification.documents.max_files", "10", "integer", False),
|
||||
("notification.instruction.allowed_hosts", "chat.example.ru", "string_list", False),
|
||||
("notification.expire_job.run_at", "00:01", "string", False),
|
||||
("notification.upload_draft.ttl_days", "7", "integer", False),
|
||||
("rate_limit.notifications_read.per_user", "120/minute", "string", False),
|
||||
("rate_limit.notifications_action.per_user", "60/minute", "string", False),
|
||||
("rate_limit.notification_upload.per_user", "20/minute", "string", False),
|
||||
("rate_limit.notifications_public.per_ip", "60/minute", "string", False),
|
||||
]
|
||||
statement = sa.text(
|
||||
"""
|
||||
INSERT INTO han_app.app_settings
|
||||
(setting_key,setting_value,value_type,is_public,record_status,updated_at)
|
||||
VALUES (:key,:value,:kind,: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()
|
||||
"""
|
||||
)
|
||||
for key, value, kind, public in settings:
|
||||
bind.execute(statement, {"key": key, "value": value, "kind": kind, "public": public})
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("Notification Center migration is forward-only")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Seed configurable maximum chat message length.
|
||||
|
||||
Revision ID: 0009_chat_message_max
|
||||
Revises: 0008_notifications_v1
|
||||
Create Date: 2026-07-29
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0009_chat_message_max"
|
||||
down_revision: str | None = "0008_notifications_v1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO han_app.app_settings
|
||||
(setting_key, setting_value, value_type, is_public, description,
|
||||
record_status, updated_at)
|
||||
VALUES
|
||||
('chat.message.max_length', '4000', 'integer', true,
|
||||
'Maximum normalized client chat message length', 'A', now())
|
||||
ON CONFLICT (setting_key) DO NOTHING
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("Chat message maximum length migration is forward-only")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Deduplicate contact mapping and skip no-op identity updates.
|
||||
|
||||
Revision ID: 0010_contact_map_dedup
|
||||
Revises: 0009_chat_message_max
|
||||
Create Date: 2026-08-05
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0010_contact_map_dedup"
|
||||
down_revision: str | None = "0009_chat_message_max"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = han_app, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
v_entity_id uuid;
|
||||
v_task_type text;
|
||||
v_dedup text;
|
||||
BEGIN
|
||||
IF current_setting('han.sync_suppress', true) = 'true' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF TG_TABLE_NAME = 'user_identities' THEN
|
||||
IF TG_OP = 'UPDATE'
|
||||
AND NEW.phone_number IS NOT DISTINCT FROM OLD.phone_number
|
||||
AND NEW.record_status IS NOT DISTINCT FROM OLD.record_status THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
v_entity_id := NEW.id;
|
||||
v_task_type := CASE WHEN TG_OP = 'INSERT'
|
||||
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
||||
ELSE
|
||||
v_entity_id := NEW.user_id;
|
||||
v_task_type := CASE WHEN TG_OP = 'INSERT'
|
||||
THEN 'contact.map_or_create' ELSE 'contact.update' END;
|
||||
END IF;
|
||||
|
||||
IF v_task_type = 'contact.map_or_create' THEN
|
||||
-- Identity and profile are inserted during the same bootstrap.
|
||||
-- A stable key collapses both triggers into one logical task.
|
||||
v_dedup := v_task_type || ':' || v_entity_id::text;
|
||||
ELSE
|
||||
v_dedup := v_task_type || ':' || v_entity_id::text || ':' ||
|
||||
encode(public.digest(row_to_json(NEW)::text, 'sha256'), 'hex');
|
||||
END IF;
|
||||
|
||||
INSERT INTO han_app.sync_queue
|
||||
(id, task_type, entity_type, entity_id, dedup_key, payload_json,
|
||||
status, attempt_count, next_attempt_at, created_at, updated_at)
|
||||
VALUES
|
||||
(gen_random_uuid(), v_task_type, 'contact', v_entity_id, v_dedup,
|
||||
jsonb_build_object('entity_id', v_entity_id), 'pending', 0,
|
||||
now(), now(), now())
|
||||
ON CONFLICT (dedup_key) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("Contact map-or-create deduplication migration is forward-only")
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Expand module-07 queue and stage canonical Bitrix mapping.
|
||||
|
||||
Revision ID: 0011_module07_contract
|
||||
Revises: 0010_contact_map_dedup
|
||||
Create Date: 2026-08-06
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0011_module07_contract"
|
||||
down_revision: str | None = "0010_contact_map_dedup"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Expand first. Existing rows remain readable throughout the migration.
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.sync_queue
|
||||
ADD COLUMN IF NOT EXISTS locked_by varchar(128),
|
||||
ADD COLUMN IF NOT EXISTS locked_until timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS lease_token uuid,
|
||||
ADD COLUMN IF NOT EXISTS last_error_code varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS last_error_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS completed_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS cancel_reason varchar(255);
|
||||
|
||||
UPDATE han_app.sync_queue
|
||||
SET status = CASE status
|
||||
WHEN 'processing' THEN 'pending'
|
||||
WHEN 'failed' THEN 'retry_wait'
|
||||
ELSE status
|
||||
END
|
||||
WHERE status IN ('processing', 'failed');
|
||||
|
||||
ALTER TABLE han_app.sync_queue
|
||||
DROP CONSTRAINT IF EXISTS sync_queue_status_check;
|
||||
ALTER TABLE han_app.sync_queue
|
||||
ADD CONSTRAINT sync_queue_status_check CHECK (
|
||||
status IN ('pending','leased','processed','retry_wait','dead_letter','cancelled')
|
||||
) NOT VALID;
|
||||
ALTER TABLE han_app.sync_queue
|
||||
VALIDATE CONSTRAINT sync_queue_status_check;
|
||||
|
||||
ALTER TABLE han_app.sync_queue
|
||||
DROP CONSTRAINT IF EXISTS sync_queue_dedup_key_key;
|
||||
DROP INDEX IF EXISTS han_app.ix_sync_queue_status_next;
|
||||
CREATE INDEX IF NOT EXISTS ix_sync_queue_claim
|
||||
ON han_app.sync_queue(status, next_attempt_at, created_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_sync_queue_expired_lease
|
||||
ON han_app.sync_queue(locked_until) WHERE status = 'leased';
|
||||
CREATE INDEX IF NOT EXISTS ix_sync_queue_entity_history
|
||||
ON han_app.sync_queue(entity_type, entity_id, created_at DESC);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_sync_queue_active_dedup
|
||||
ON han_app.sync_queue(dedup_key)
|
||||
WHERE status IN ('pending','leased','retry_wait');
|
||||
"""
|
||||
)
|
||||
|
||||
# The bitrix-sync migration owns the canonical schema. If that migration
|
||||
# already ran, copy and verify legacy rows here; otherwise its follow-up
|
||||
# migration performs the same copy. The legacy table remains until the
|
||||
# readers have switched and the contract migration is explicitly approved.
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('bitrix_sync.entity_external_mapping') IS NOT NULL THEN
|
||||
INSERT INTO bitrix_sync.entity_external_mapping (
|
||||
id, entity_type, entity_id, external_system, external_entity_type,
|
||||
external_id, status, opened_at, created_at, updated_at
|
||||
)
|
||||
SELECT id, entity_type, entity_id, 'bitrix24', 'contact',
|
||||
external_id, 'active', created_at, created_at, created_at
|
||||
FROM han_app.entity_external_mapping
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM han_app.entity_external_mapping legacy
|
||||
LEFT JOIN bitrix_sync.entity_external_mapping canonical
|
||||
ON canonical.id = legacy.id
|
||||
AND canonical.entity_type = legacy.entity_type
|
||||
AND canonical.entity_id = legacy.entity_id
|
||||
AND canonical.external_id = legacy.external_id
|
||||
WHERE canonical.id IS NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'canonical mapping verification failed';
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = han_app, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id uuid;
|
||||
v_task_type varchar(64);
|
||||
v_reason varchar(64);
|
||||
v_dedup varchar(255);
|
||||
v_source_updated_at timestamptz;
|
||||
BEGIN
|
||||
IF current_setting('han.sync_suppress', true) = 'true' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF TG_TABLE_NAME = 'user_identities' THEN
|
||||
v_user_id := NEW.id;
|
||||
v_source_updated_at := NEW.updated_at;
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW.record_status <> 'A' THEN RETURN NEW; END IF;
|
||||
v_task_type := 'contact.map_or_create';
|
||||
v_reason := 'identity_created';
|
||||
ELSIF OLD.record_status = 'A' AND NEW.record_status <> 'A' THEN
|
||||
v_task_type := 'contact.deactivate';
|
||||
v_reason := 'identity_deactivated';
|
||||
ELSIF OLD.record_status <> 'A' AND NEW.record_status = 'A' THEN
|
||||
v_task_type := 'contact.map_or_create';
|
||||
v_reason := 'identity_reactivated';
|
||||
ELSIF NEW.record_status = 'A'
|
||||
AND NEW.phone_number IS DISTINCT FROM OLD.phone_number THEN
|
||||
v_task_type := 'contact.update';
|
||||
v_reason := 'identity_phone_changed';
|
||||
ELSE
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
ELSE
|
||||
v_user_id := NEW.user_id;
|
||||
v_source_updated_at := NEW.updated_at;
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW.record_status <> 'A' THEN RETURN NEW; END IF;
|
||||
v_task_type := 'contact.map_or_create';
|
||||
v_reason := 'profile_created';
|
||||
ELSIF OLD.record_status = 'A' AND NEW.record_status <> 'A' THEN
|
||||
v_task_type := 'contact.deactivate';
|
||||
v_reason := 'profile_deactivated';
|
||||
ELSIF OLD.record_status <> 'A' AND NEW.record_status = 'A' THEN
|
||||
v_task_type := 'contact.map_or_create';
|
||||
v_reason := 'profile_reactivated';
|
||||
ELSE
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_dedup := v_task_type || ':' || v_user_id::text;
|
||||
INSERT INTO han_app.sync_queue (
|
||||
id, task_type, entity_type, entity_id, dedup_key, payload_json,
|
||||
status, attempt_count, next_attempt_at, created_at, updated_at
|
||||
) VALUES (
|
||||
gen_random_uuid(), v_task_type, 'contact', v_user_id, v_dedup,
|
||||
jsonb_build_object(
|
||||
'schema_version', 1,
|
||||
'user_id', v_user_id,
|
||||
'reason', v_reason,
|
||||
'source_updated_at', v_source_updated_at
|
||||
),
|
||||
'pending', 0, now(), now(), now()
|
||||
)
|
||||
ON CONFLICT (dedup_key)
|
||||
WHERE status IN ('pending','leased','retry_wait')
|
||||
DO UPDATE SET
|
||||
payload_json = EXCLUDED.payload_json,
|
||||
updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles;
|
||||
CREATE TRIGGER trg_profile_contact_sync
|
||||
AFTER INSERT OR UPDATE OF record_status
|
||||
ON han_app.client_profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync();
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("Module-07 staged contract migration is forward-only")
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Persist Message Safety v2 evidence and recovery locations.
|
||||
|
||||
Revision ID: 0012_safety_v2_checkpoint
|
||||
Revises: 0011_module07_contract
|
||||
Create Date: 2026-08-06
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0012_safety_v2_checkpoint"
|
||||
down_revision: str | None = "0011_module07_contract"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.messages
|
||||
ADD COLUMN IF NOT EXISTS safety_processing_mode varchar(16),
|
||||
ADD COLUMN IF NOT EXISTS safety_config_version bigint,
|
||||
ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128);
|
||||
|
||||
ALTER TABLE han_app.message_attachments
|
||||
ADD COLUMN IF NOT EXISTS quarantine_version_id varchar(1024),
|
||||
ADD COLUMN IF NOT EXISTS quarantine_etag varchar(1024);
|
||||
ALTER TABLE han_app.message_attachments
|
||||
DROP CONSTRAINT IF EXISTS message_attachments_scan_status_check;
|
||||
ALTER TABLE han_app.message_attachments
|
||||
ADD CONSTRAINT message_attachments_scan_status_check
|
||||
CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID;
|
||||
ALTER TABLE han_app.message_attachments
|
||||
VALIDATE CONSTRAINT message_attachments_scan_status_check;
|
||||
|
||||
ALTER TABLE han_app.safety_tasks
|
||||
ADD COLUMN IF NOT EXISTS poll_location varchar(1024),
|
||||
ADD COLUMN IF NOT EXISTS processing_mode varchar(16),
|
||||
ADD COLUMN IF NOT EXISTS config_version bigint,
|
||||
ADD COLUMN IF NOT EXISTS rules_version varchar(128),
|
||||
ADD COLUMN IF NOT EXISTS expires_at timestamptz;
|
||||
UPDATE han_app.safety_tasks
|
||||
SET poll_location = '/internal/safety/v2/messages/tasks/' || task_id,
|
||||
expires_at = deadline_at
|
||||
WHERE poll_location IS NULL OR expires_at IS NULL;
|
||||
ALTER TABLE han_app.safety_tasks
|
||||
ALTER COLUMN poll_location SET NOT NULL,
|
||||
ALTER COLUMN expires_at SET NOT NULL;
|
||||
"""
|
||||
)
|
||||
|
||||
# Notification uploads use the same safety contract.
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE han_app.client_upload_drafts
|
||||
ADD COLUMN IF NOT EXISTS quarantine_version_id varchar(1024),
|
||||
ADD COLUMN IF NOT EXISTS quarantine_etag varchar(1024),
|
||||
ADD COLUMN IF NOT EXISTS safety_processing_mode varchar(16),
|
||||
ADD COLUMN IF NOT EXISTS safety_config_version bigint,
|
||||
ADD COLUMN IF NOT EXISTS safety_rules_version varchar(128);
|
||||
ALTER TABLE han_app.client_upload_drafts
|
||||
DROP CONSTRAINT IF EXISTS client_upload_drafts_scan_status_check;
|
||||
ALTER TABLE han_app.client_upload_drafts
|
||||
ADD CONSTRAINT client_upload_drafts_scan_status_check
|
||||
CHECK (scan_status IN ('pending','clean','bypassed','infected','failed')) NOT VALID;
|
||||
ALTER TABLE han_app.client_upload_drafts
|
||||
VALIDATE CONSTRAINT client_upload_drafts_scan_status_check;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError("Safety v2 checkpoint migration is forward-only")
|
||||
@@ -0,0 +1 @@
|
||||
"""HAN Chat API backend."""
|
||||
@@ -0,0 +1,110 @@
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import phonenumbers
|
||||
from jwt import ExpiredSignatureError, InvalidTokenError, PyJWK
|
||||
|
||||
from app.settings import Settings
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
def __init__(self, code: str = "unauthorized") -> None:
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Principal:
|
||||
subject: str
|
||||
phone_number: str | None
|
||||
claims: dict[str, Any]
|
||||
|
||||
|
||||
class JWKSValidator:
|
||||
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
|
||||
self.settings = settings
|
||||
self.http = http
|
||||
self._keys: dict[str, PyJWK] = {}
|
||||
self._loaded_at = 0.0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def has_keys(self) -> bool:
|
||||
return bool(self._keys)
|
||||
|
||||
async def refresh(self) -> None:
|
||||
async with self._lock:
|
||||
discovery_url = (
|
||||
f"{str(self.settings.keycloak_internal_url).rstrip('/')}"
|
||||
f"/realms/{self.settings.keycloak_realm}/.well-known/openid-configuration"
|
||||
)
|
||||
discovery = (await self.http.get(discovery_url, timeout=3)).raise_for_status().json()
|
||||
jwks_uri = discovery["jwks_uri"]
|
||||
public_prefix = str(self.settings.keycloak_public_url).rstrip("/")
|
||||
internal_prefix = str(self.settings.keycloak_internal_url).rstrip("/")
|
||||
if jwks_uri.startswith(public_prefix):
|
||||
jwks_uri = internal_prefix + jwks_uri[len(public_prefix) :]
|
||||
payload = (await self.http.get(jwks_uri, timeout=3)).raise_for_status().json()
|
||||
self._keys = {
|
||||
key["kid"]: PyJWK.from_dict(key)
|
||||
for key in payload.get("keys", [])
|
||||
if key.get("kid") and key.get("kty") == "RSA"
|
||||
}
|
||||
self._loaded_at = time.monotonic()
|
||||
|
||||
async def validate(self, token: str) -> Principal:
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
except InvalidTokenError as exc:
|
||||
raise AuthError() from exc
|
||||
if header.get("alg") != "RS256" or not header.get("kid"):
|
||||
raise AuthError()
|
||||
kid = str(header["kid"])
|
||||
stale = time.monotonic() - self._loaded_at > self.settings.jwks_cache_ttl_seconds
|
||||
if stale or kid not in self._keys:
|
||||
try:
|
||||
await self.refresh()
|
||||
except (httpx.HTTPError, KeyError, ValueError):
|
||||
if (
|
||||
kid not in self._keys
|
||||
or time.monotonic() - self._loaded_at > self.settings.jwks_stale_grace_seconds
|
||||
):
|
||||
raise AuthError() from None
|
||||
key = self._keys.get(kid)
|
||||
if key is None:
|
||||
raise AuthError()
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
key.key,
|
||||
algorithms=["RS256"],
|
||||
audience=self.settings.keycloak_audience,
|
||||
issuer=self.settings.issuer,
|
||||
options={"require": ["exp", "sub"]},
|
||||
leeway=30,
|
||||
)
|
||||
except ExpiredSignatureError as exc:
|
||||
raise AuthError("token_expired") from exc
|
||||
except InvalidTokenError as exc:
|
||||
raise AuthError() from exc
|
||||
subject = claims.get("sub")
|
||||
if not isinstance(subject, str) or not subject:
|
||||
raise AuthError()
|
||||
return Principal(subject, canonical_phone(claims), claims)
|
||||
|
||||
|
||||
def canonical_phone(claims: dict[str, Any]) -> str | None:
|
||||
for name in ("phone_number", "preferred_username"):
|
||||
value = claims.get(name)
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
try:
|
||||
number = phonenumbers.parse(value, None)
|
||||
except phonenumbers.NumberParseException:
|
||||
continue
|
||||
if phonenumbers.is_valid_number(number) and value.startswith("+"):
|
||||
return phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)
|
||||
return None
|
||||
@@ -0,0 +1,21 @@
|
||||
from collections.abc import Mapping
|
||||
|
||||
CHAT_MESSAGE_MAX_LENGTH_KEY = "chat.message.max_length"
|
||||
CHAT_MESSAGE_TRANSPORT_MAX_LENGTH = 10_000
|
||||
|
||||
|
||||
def validate_chat_settings(values: Mapping[str, str]) -> None:
|
||||
raw = values.get(CHAT_MESSAGE_MAX_LENGTH_KEY)
|
||||
if raw is None:
|
||||
return
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError(f"{CHAT_MESSAGE_MAX_LENGTH_KEY}: integer value expected") from error
|
||||
if str(value) != raw:
|
||||
raise ValueError(f"{CHAT_MESSAGE_MAX_LENGTH_KEY}: canonical integer value expected")
|
||||
if not 1 <= value <= CHAT_MESSAGE_TRANSPORT_MAX_LENGTH:
|
||||
raise ValueError(
|
||||
f"{CHAT_MESSAGE_MAX_LENGTH_KEY}: value must be between 1 "
|
||||
f"and {CHAT_MESSAGE_TRANSPORT_MAX_LENGTH}"
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Operational command-line entry points."""
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
|
||||
from app.chat_settings import CHAT_MESSAGE_MAX_LENGTH_KEY, validate_chat_settings
|
||||
from app.db import AppSetting, Database
|
||||
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
|
||||
from app.settings import get_settings
|
||||
|
||||
VALUE_TYPES = {"boolean", "integer", "string", "string_list"}
|
||||
|
||||
|
||||
def load_seed(path: Path) -> list[dict[str, Any]]:
|
||||
document = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(document, dict) or document.get("schema_version") != 1:
|
||||
raise ValueError("settings file must have schema_version: 1")
|
||||
settings = document.get("settings")
|
||||
if not isinstance(settings, dict) or not settings:
|
||||
raise ValueError("settings file must contain a non-empty settings mapping")
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for key, raw in settings.items():
|
||||
if not isinstance(key, str) or not key.strip():
|
||||
raise ValueError("setting keys must be non-empty strings")
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{key}: setting must be a mapping")
|
||||
value_type = raw.get("type")
|
||||
if value_type not in VALUE_TYPES:
|
||||
raise ValueError(f"{key}: unsupported type {value_type!r}")
|
||||
if key in OTP_SETTING_KEYS and value_type != "integer":
|
||||
raise ValueError(f"{key}: type must be integer")
|
||||
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and value_type != "integer":
|
||||
raise ValueError(f"{key}: type must be integer")
|
||||
if not isinstance(raw.get("public"), bool):
|
||||
raise ValueError(f"{key}: public must be a boolean")
|
||||
if key in OTP_SETTING_KEYS and raw["public"]:
|
||||
raise ValueError(f"{key}: OTP setting must not be public")
|
||||
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and not raw["public"]:
|
||||
raise ValueError(f"{key}: setting must be public")
|
||||
description = raw.get("description")
|
||||
if description is not None and not isinstance(description, str):
|
||||
raise ValueError(f"{key}: description must be a string")
|
||||
rows.append(
|
||||
{
|
||||
"setting_key": key,
|
||||
"setting_value": serialize_value(key, value_type, raw.get("value")),
|
||||
"value_type": value_type,
|
||||
"is_public": raw["public"],
|
||||
"description": description,
|
||||
"record_status": "A",
|
||||
}
|
||||
)
|
||||
validate_otp_settings({row["setting_key"]: row["setting_value"] for row in rows})
|
||||
validate_chat_settings({row["setting_key"]: row["setting_value"] for row in rows})
|
||||
return rows
|
||||
|
||||
|
||||
def serialize_value(key: str, value_type: str, value: Any) -> str:
|
||||
if value_type == "boolean":
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"{key}: boolean value expected")
|
||||
return str(value).lower()
|
||||
if value_type == "integer":
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ValueError(f"{key}: integer value expected")
|
||||
return str(value)
|
||||
if value_type == "string_list":
|
||||
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
||||
return ",".join(value)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
raise ValueError(f"{key}: string or list of strings expected")
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"{key}: string value expected")
|
||||
return value
|
||||
|
||||
|
||||
async def seed(path: Path) -> int:
|
||||
rows = load_seed(path)
|
||||
database = Database(get_settings().database_url)
|
||||
try:
|
||||
async with database.sessions() as session:
|
||||
for row in rows:
|
||||
statement = insert(AppSetting).values(**row)
|
||||
excluded = statement.excluded
|
||||
statement = statement.on_conflict_do_update(
|
||||
index_elements=[AppSetting.setting_key],
|
||||
set_={
|
||||
"setting_value": excluded.setting_value,
|
||||
"value_type": excluded.value_type,
|
||||
"is_public": excluded.is_public,
|
||||
"description": excluded.description,
|
||||
"record_status": "A",
|
||||
"updated_at": func.now(),
|
||||
},
|
||||
where=or_(
|
||||
AppSetting.setting_value.is_distinct_from(excluded.setting_value),
|
||||
AppSetting.value_type.is_distinct_from(excluded.value_type),
|
||||
AppSetting.is_public.is_distinct_from(excluded.is_public),
|
||||
AppSetting.description.is_distinct_from(excluded.description),
|
||||
AppSetting.record_status != "A",
|
||||
),
|
||||
)
|
||||
await session.execute(statement)
|
||||
await session.commit()
|
||||
finally:
|
||||
await database.close()
|
||||
return len(rows)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Idempotently seed application settings")
|
||||
parser.add_argument("--file", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
count = asyncio.run(seed(args.file))
|
||||
print(f"Application settings seeded: {count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import AppSetting, Database
|
||||
from app.services import REQUIRED_SETTINGS
|
||||
from app.settings import get_settings
|
||||
|
||||
|
||||
async def validate() -> int:
|
||||
database = Database(get_settings().database_url)
|
||||
try:
|
||||
async with database.sessions() as session:
|
||||
active_keys = set(
|
||||
(
|
||||
await session.execute(
|
||||
select(AppSetting.setting_key).where(AppSetting.record_status == "A")
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
missing = sorted(REQUIRED_SETTINGS - active_keys)
|
||||
if missing:
|
||||
raise RuntimeError(f"Mandatory application settings are missing: {', '.join(missing)}")
|
||||
return len(REQUIRED_SETTINGS)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
count = asyncio.run(validate())
|
||||
print(f"Mandatory application settings validated: {count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,435 @@
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import INET, JSONB, UUID
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
from app.postgres import create_postgres_engine
|
||||
|
||||
SCHEMA = "han_app"
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
type_annotation_map = {dict[str, Any]: JSON}
|
||||
|
||||
|
||||
class BitrixBase(DeclarativeBase):
|
||||
"""Models owned by bitrix-sync, excluded from han_app create_all."""
|
||||
|
||||
|
||||
class Common:
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
record_status: Mapped[str] = mapped_column(String(1), default="A", server_default="A")
|
||||
status_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
status_change_reason: Mapped[str | None] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
updater_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
|
||||
|
||||
class UserIdentity(Common, Base):
|
||||
__tablename__ = "user_identities"
|
||||
__table_args__ = (Index("ix_user_identities_phone", "phone_number"), {"schema": SCHEMA})
|
||||
keycloak_sub: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
phone_number: Mapped[str] = mapped_column(String(32))
|
||||
last_login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class ClientProfile(Common, Base):
|
||||
__tablename__ = "client_profiles"
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT"), unique=True
|
||||
)
|
||||
bitrix_contact_id: Mapped[str | None] = mapped_column(String(64))
|
||||
full_name: Mapped[str | None] = mapped_column(String(255))
|
||||
citizenship: Mapped[str | None] = mapped_column(String(128))
|
||||
russian_phone: Mapped[str | None] = mapped_column(String(32))
|
||||
foreign_phone: Mapped[str | None] = mapped_column(String(32))
|
||||
email: Mapped[str | None] = mapped_column(String(320))
|
||||
source_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class UserConsent(Common, Base):
|
||||
__tablename__ = "user_consents"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "consent_type", "document_version"),
|
||||
CheckConstraint("consent_type IN ('personal_data','user_agreement','marketing')"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
|
||||
)
|
||||
ux_session_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
consent_type: Mapped[str] = mapped_column(String(32))
|
||||
document_version: Mapped[str] = mapped_column(String(64))
|
||||
accepted: Mapped[bool] = mapped_column(Boolean)
|
||||
accepted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
client_ip: Mapped[str | None] = mapped_column(INET)
|
||||
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
|
||||
device_json: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
|
||||
|
||||
|
||||
class UxSession(Common, Base):
|
||||
__tablename__ = "ux_sessions"
|
||||
__table_args__ = (
|
||||
CheckConstraint("start_reason IN ('first_launch','cold_start','idle_timeout')"),
|
||||
Index("ix_ux_sessions_user_started", "user_id", "started_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
|
||||
)
|
||||
start_reason: Mapped[str] = mapped_column(String(32))
|
||||
platform: Mapped[str] = mapped_column(String(32))
|
||||
app_version: Mapped[str] = mapped_column(String(64))
|
||||
device_id_hash: Mapped[str | None] = mapped_column(String(64))
|
||||
device_id: Mapped[str | None] = mapped_column(String(255))
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Dialog(Common, Base):
|
||||
__tablename__ = "dialogs"
|
||||
__table_args__ = (
|
||||
CheckConstraint("status IN ('open','waiting_for_company','waiting_for_client','closed')"),
|
||||
Index("ix_dialogs_user_updated", "user_id", "updated_at", "id"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(32), default="open")
|
||||
last_message_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Message(Common, Base):
|
||||
__tablename__ = "messages"
|
||||
__table_args__ = (
|
||||
CheckConstraint("sender_type IN ('client','company')"),
|
||||
CheckConstraint("content_kind IN ('text','file')"),
|
||||
CheckConstraint("safety_status IN ('pending','allowed','blocked','needs_review')"),
|
||||
CheckConstraint(
|
||||
"delivery_status IN ('accepted','processing','delivered','rejected','failed')"
|
||||
),
|
||||
Index("ix_messages_dialog_created", "dialog_id", "created_at", "id"),
|
||||
UniqueConstraint("dialog_id", "client_idempotency_key"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
dialog_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.dialogs.id", ondelete="RESTRICT")
|
||||
)
|
||||
sender_type: Mapped[str] = mapped_column(String(16))
|
||||
content_kind: Mapped[str] = mapped_column(String(16))
|
||||
text: Mapped[str] = mapped_column(Text, default="")
|
||||
safety_status: Mapped[str] = mapped_column(String(16))
|
||||
safety_processing_mode: Mapped[str | None] = mapped_column(String(16))
|
||||
safety_config_version: Mapped[int | None] = mapped_column(BigInteger)
|
||||
safety_rules_version: Mapped[str | None] = mapped_column(String(128))
|
||||
delivery_status: Mapped[str] = mapped_column(String(16))
|
||||
external_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||
client_idempotency_key: Mapped[str | None] = mapped_column(String(128))
|
||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class MessageAttachment(Common, Base):
|
||||
__tablename__ = "message_attachments"
|
||||
__table_args__ = (
|
||||
CheckConstraint("direction IN ('client_upload','company_inbound')"),
|
||||
CheckConstraint("scan_status IN ('pending','clean','bypassed','infected','failed')"),
|
||||
CheckConstraint("size_bytes > 0"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
dialog_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.dialogs.id"))
|
||||
message_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.messages.id"), unique=True
|
||||
)
|
||||
owner_user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.user_identities.id"))
|
||||
direction: Mapped[str] = mapped_column(String(32))
|
||||
original_file_name: Mapped[str] = mapped_column(String(255))
|
||||
safe_file_name: Mapped[str] = mapped_column(String(255))
|
||||
mime_type: Mapped[str] = mapped_column(String(128))
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||
checksum_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||
scan_status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||
storage_bucket: Mapped[str] = mapped_column(String(255))
|
||||
object_key: Mapped[str] = mapped_column(String(1024))
|
||||
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
|
||||
quarantine_version_id: Mapped[str | None] = mapped_column(String(1024))
|
||||
quarantine_etag: Mapped[str | None] = mapped_column(String(1024))
|
||||
upload_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Document(Common, Base):
|
||||
__tablename__ = "documents"
|
||||
__table_args__ = (UniqueConstraint("storage_bucket", "object_key"), {"schema": SCHEMA})
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.user_identities.id"))
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
mime_type: Mapped[str] = mapped_column(String(128))
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||
checksum_sha256: Mapped[str] = mapped_column(String(64))
|
||||
storage_bucket: Mapped[str] = mapped_column(String(255))
|
||||
object_key: Mapped[str] = mapped_column(String(1024))
|
||||
sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class SafetyTask(Base):
|
||||
__tablename__ = "safety_tasks"
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
task_id: Mapped[str] = mapped_column(String(255), unique=True)
|
||||
poll_location: Mapped[str] = mapped_column(String(1024))
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
|
||||
attachment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
|
||||
status: Mapped[str] = mapped_column(String(16))
|
||||
processing_mode: Mapped[str | None] = mapped_column(String(16))
|
||||
config_version: Mapped[int | None] = mapped_column(BigInteger)
|
||||
rules_version: Mapped[str | None] = mapped_column(String(128))
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
deadline_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
next_poll_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
locked_by: Mapped[str | None] = mapped_column(String(128))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class DeliveryOutbox(Base):
|
||||
__tablename__ = "delivery_outbox"
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
|
||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
locked_by: Mapped[str | None] = mapped_column(String(128))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class IdempotencyRecord(Base):
|
||||
__tablename__ = "idempotency_records"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("scope", "user_id", "idempotency_key"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
scope: Mapped[str] = mapped_column(String(128))
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
idempotency_key: Mapped[str] = mapped_column(String(128))
|
||||
request_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||
status: Mapped[str] = mapped_column(String(16))
|
||||
response_status: Mapped[int | None] = mapped_column(Integer)
|
||||
response_body_json: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||
resource_type: Mapped[str | None] = mapped_column(String(64))
|
||||
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class OpenLinesInboxReceipt(Base):
|
||||
__tablename__ = "openlines_inbox_receipts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("event_id"),
|
||||
UniqueConstraint("external_chat_id", "bitrix_message_id"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
event_id: Mapped[str] = mapped_column(String(255))
|
||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||
event_type: Mapped[str] = mapped_column(String(32))
|
||||
payload_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||
status: Mapped[str] = mapped_column(String(16))
|
||||
message_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class AuditEvent(Base):
|
||||
__tablename__ = "audit_events"
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
event_type: Mapped[str] = mapped_column(String(128))
|
||||
actor_type: Mapped[str] = mapped_column(String(32))
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
ux_session_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
request_id: Mapped[str] = mapped_column(String(64))
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64))
|
||||
resource_type: Mapped[str | None] = mapped_column(String(64))
|
||||
resource_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
user_agent_hash: Mapped[str | None] = mapped_column(String(64))
|
||||
outcome: Mapped[str] = mapped_column(String(32))
|
||||
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
__tablename__ = "app_settings"
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
setting_key: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||
setting_value: Mapped[str] = mapped_column(Text)
|
||||
value_type: Mapped[str] = mapped_column(String(32))
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
record_status: Mapped[str] = mapped_column(String(1), default="A")
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class TextResource(Common, Base):
|
||||
__tablename__ = "text_resources"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("mnemonic", "locale"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
mnemonic: Mapped[str] = mapped_column(String(255))
|
||||
locale: Mapped[str] = mapped_column(String(16), default="ru")
|
||||
text_value: Mapped[str] = mapped_column(Text)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
class PopularQuestion(Common, Base):
|
||||
__tablename__ = "popular_questions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("mnemonic", "locale"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
mnemonic: Mapped[str] = mapped_column(String(255))
|
||||
locale: Mapped[str] = mapped_column(String(16), default="ru")
|
||||
question_text: Mapped[str] = mapped_column(Text)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
class SyncQueue(Base):
|
||||
__tablename__ = "sync_queue"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('pending','leased','processed','retry_wait','dead_letter','cancelled')"
|
||||
),
|
||||
Index("ix_sync_queue_claim", "status", "next_attempt_at", "created_at"),
|
||||
Index("ix_sync_queue_entity_history", "entity_type", "entity_id", "created_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
task_type: Mapped[str] = mapped_column(String(64))
|
||||
entity_type: Mapped[str] = mapped_column(String(64))
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
dedup_key: Mapped[str] = mapped_column(String(255))
|
||||
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
locked_by: Mapped[str | None] = mapped_column(String(128))
|
||||
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
lease_token: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
last_error_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
cancel_reason: Mapped[str | None] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class EntityExternalMapping(Base):
|
||||
__tablename__ = "entity_external_mapping"
|
||||
__table_args__ = (UniqueConstraint("entity_type", "entity_id"), {"schema": SCHEMA})
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
entity_type: Mapped[str] = mapped_column(String(64))
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
external_id: Mapped[str] = mapped_column(String(128))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class BitrixEntityExternalMapping(BitrixBase):
|
||||
__tablename__ = "entity_external_mapping"
|
||||
__table_args__ = ({"schema": "bitrix_sync"},)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
entity_type: Mapped[str] = mapped_column(String(64))
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
external_system: Mapped[str] = mapped_column(String(32), default="bitrix24")
|
||||
external_entity_type: Mapped[str] = mapped_column(String(32), default="contact")
|
||||
external_id: Mapped[str] = mapped_column(String(128))
|
||||
status: Mapped[str] = mapped_column(String(16), default="active")
|
||||
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
close_reason: Mapped[str | None] = mapped_column(String(64))
|
||||
workflow_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
Index(
|
||||
"uq_sync_queue_active_dedup",
|
||||
SyncQueue.dedup_key,
|
||||
unique=True,
|
||||
postgresql_where=SyncQueue.status.in_(["pending", "leased", "retry_wait"]),
|
||||
)
|
||||
Index(
|
||||
"ix_sync_queue_expired_lease",
|
||||
SyncQueue.locked_until,
|
||||
postgresql_where=SyncQueue.status == "leased",
|
||||
)
|
||||
Index(
|
||||
"uq_external_mapping_active_entity",
|
||||
BitrixEntityExternalMapping.external_system,
|
||||
BitrixEntityExternalMapping.entity_type,
|
||||
BitrixEntityExternalMapping.entity_id,
|
||||
unique=True,
|
||||
postgresql_where=BitrixEntityExternalMapping.status == "active",
|
||||
)
|
||||
Index(
|
||||
"uq_external_mapping_active_external",
|
||||
BitrixEntityExternalMapping.external_system,
|
||||
BitrixEntityExternalMapping.external_entity_type,
|
||||
BitrixEntityExternalMapping.external_id,
|
||||
unique=True,
|
||||
postgresql_where=BitrixEntityExternalMapping.status == "active",
|
||||
)
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, url: str) -> None:
|
||||
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]:
|
||||
async with self.sessions() as session:
|
||||
yield session
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.engine.dispose()
|
||||
@@ -0,0 +1,460 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import boto3
|
||||
import httpx
|
||||
from botocore.config import Config
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from app.settings import Settings
|
||||
|
||||
RATE_LIMIT_LUA = """
|
||||
local current = redis.call('INCR', KEYS[1])
|
||||
if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
|
||||
local ttl = redis.call('TTL', KEYS[1])
|
||||
return {current, ttl}
|
||||
"""
|
||||
|
||||
|
||||
class DependencyFailure(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
code: str = "dependency_unavailable",
|
||||
timeout: bool = False,
|
||||
*,
|
||||
terminal: bool = False,
|
||||
retryable: bool = True,
|
||||
) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.timeout = timeout
|
||||
self.terminal = terminal
|
||||
self.retryable = retryable
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CircuitBreaker:
|
||||
threshold: int
|
||||
open_seconds: float
|
||||
failures: int = 0
|
||||
opened_at: float | None = None
|
||||
|
||||
def allow(self) -> bool:
|
||||
if self.opened_at is None:
|
||||
return True
|
||||
if time.monotonic() - self.opened_at >= self.open_seconds:
|
||||
self.opened_at = None
|
||||
self.failures = max(0, self.threshold - 1)
|
||||
return True
|
||||
return False
|
||||
|
||||
def success(self) -> None:
|
||||
self.failures = 0
|
||||
self.opened_at = None
|
||||
|
||||
def failure(self) -> None:
|
||||
self.failures += 1
|
||||
if self.failures >= self.threshold:
|
||||
self.opened_at = time.monotonic()
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
self.redis = redis
|
||||
|
||||
async def consume(self, key: str, limit: int, window: int) -> int:
|
||||
try:
|
||||
count, ttl = await self.redis.eval(RATE_LIMIT_LUA, 1, key, window)
|
||||
except Exception as exc:
|
||||
raise DependencyFailure() from exc
|
||||
if int(count) > limit:
|
||||
return max(1, int(ttl))
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def key(identity_type: str, identity: str, route: str, window: int) -> str:
|
||||
safe_identity = hashlib.sha256(identity.encode()).hexdigest()[:32]
|
||||
bucket = int(time.time()) // window
|
||||
return f"han:api:rl:{identity_type}:{safe_identity}:{route}:{bucket}"
|
||||
|
||||
|
||||
class RedisIdempotency:
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
self.redis = redis
|
||||
|
||||
async def get(self, scope: str, user_id: uuid.UUID, key: str) -> dict[str, Any] | None:
|
||||
key_hash = hashlib.sha256(key.encode()).hexdigest()
|
||||
raw = await self.redis.get(f"han:api:idem:{scope}:{user_id}:{key_hash}")
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
async def put(self, scope: str, user_id: uuid.UUID, key: str, value: dict[str, Any]) -> None:
|
||||
key_hash = hashlib.sha256(key.encode()).hexdigest()
|
||||
await self.redis.set(
|
||||
f"han:api:idem:{scope}:{user_id}:{key_hash}",
|
||||
json.dumps(value, separators=(",", ":"), default=str),
|
||||
ex=86400,
|
||||
)
|
||||
|
||||
|
||||
class SafetyClient:
|
||||
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
|
||||
self.settings = settings
|
||||
self.http = http
|
||||
self.breaker = CircuitBreaker(
|
||||
settings.message_safety_circuit_failure_threshold,
|
||||
settings.message_safety_circuit_open_sec,
|
||||
)
|
||||
|
||||
async def check(self, payload: dict[str, Any], request_id: str) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"POST",
|
||||
f"{self.settings.message_safety_api_prefix}/messages/check",
|
||||
request_id,
|
||||
json=payload,
|
||||
timeout=self.settings.message_safety_post_timeout_sec,
|
||||
)
|
||||
|
||||
async def poll(self, location: str, request_id: str) -> dict[str, Any]:
|
||||
path = self._poll_path(location)
|
||||
return await self._call(
|
||||
"GET",
|
||||
path,
|
||||
request_id,
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
def _poll_path(self, location: str) -> str:
|
||||
expected_prefix = f"{self.settings.message_safety_api_prefix}/messages/tasks/"
|
||||
parsed = urlparse(location)
|
||||
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
|
||||
raise DependencyFailure("invalid_safety_location", terminal=True, retryable=False)
|
||||
if not parsed.path.startswith(expected_prefix):
|
||||
raise DependencyFailure("invalid_safety_location", terminal=True, retryable=False)
|
||||
task_id = parsed.path.removeprefix(expected_prefix)
|
||||
try:
|
||||
uuid.UUID(task_id)
|
||||
except ValueError as exc:
|
||||
raise DependencyFailure(
|
||||
"invalid_safety_location", terminal=True, retryable=False
|
||||
) from exc
|
||||
return parsed.path
|
||||
|
||||
async def _call(self, method: str, path: str, request_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||
if not self.breaker.allow():
|
||||
raise DependencyFailure()
|
||||
headers = {
|
||||
"X-Service-Token": self.settings.message_safety_service_token.get_secret_value(),
|
||||
"X-Request-ID": request_id,
|
||||
}
|
||||
try:
|
||||
response = await self.http.request(
|
||||
method,
|
||||
f"{str(self.settings.message_safety_url).rstrip('/')}{path}",
|
||||
headers=headers,
|
||||
**kwargs,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure(timeout=True) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure() from exc
|
||||
if response.status_code >= 500:
|
||||
self.breaker.failure()
|
||||
code, terminal, retryable = "dependency_unavailable", False, True
|
||||
try:
|
||||
details = response.json().get("error", {}).get("details", {})
|
||||
code = response.json().get("error", {}).get("code", code)
|
||||
terminal = details.get("terminal") is True
|
||||
retryable = details.get("retryable") is not False
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
raise DependencyFailure(code, terminal=terminal, retryable=retryable)
|
||||
if response.status_code not in (200, 202, 403):
|
||||
if response.status_code == 401:
|
||||
self.breaker.failure()
|
||||
code = "safety_request_rejected"
|
||||
try:
|
||||
code = response.json().get("error", {}).get("code", code)
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
retryable = response.status_code in (404, 429)
|
||||
raise DependencyFailure(
|
||||
code,
|
||||
terminal=not retryable,
|
||||
retryable=retryable,
|
||||
)
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure() from exc
|
||||
if not isinstance(body, dict):
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure()
|
||||
status = response.status_code
|
||||
expected_verdict = {200: "allow", 202: "pending", 403: "deny"}[status]
|
||||
if (
|
||||
body.get("verdict") != expected_verdict
|
||||
or body.get("processing_mode") not in ("standard", "mock")
|
||||
or type(body.get("config_version")) is not int
|
||||
or not body.get("rules_version")
|
||||
):
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response")
|
||||
if status == 202:
|
||||
location = response.headers.get("Location")
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
if (
|
||||
body["processing_mode"] != "standard"
|
||||
or not location
|
||||
or not retry_after
|
||||
or not body.get("task_id")
|
||||
or not body.get("expires_at")
|
||||
or type(body.get("poll_after_ms")) is not int
|
||||
):
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response")
|
||||
try:
|
||||
location_task_id = self._poll_path(location).rsplit("/", 1)[-1]
|
||||
except DependencyFailure as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response") from exc
|
||||
if body["task_id"] != location_task_id:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response")
|
||||
try:
|
||||
if int(retry_after) <= 0 or body["poll_after_ms"] <= 0:
|
||||
raise ValueError
|
||||
datetime_value = body["expires_at"].replace("Z", "+00:00")
|
||||
datetime.fromisoformat(datetime_value)
|
||||
except (AttributeError, TypeError, ValueError) as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response") from exc
|
||||
body["_location"] = location
|
||||
body["_retry_after"] = retry_after
|
||||
elif not body.get("rule_id") or (
|
||||
status == 403 and body.get("reason_code") != "message_blocked"
|
||||
):
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure("invalid_safety_response")
|
||||
self.breaker.success()
|
||||
body["_status"] = response.status_code
|
||||
return body
|
||||
|
||||
async def ready(self) -> bool:
|
||||
try:
|
||||
response = await self.http.get(
|
||||
f"{str(self.settings.message_safety_url).rstrip('/')}/health/ready",
|
||||
timeout=2,
|
||||
)
|
||||
return response.status_code == 200
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
|
||||
class OpenLinesClient:
|
||||
def __init__(self, settings: Settings, http: httpx.AsyncClient) -> None:
|
||||
self.settings = settings
|
||||
self.http = http
|
||||
self.breaker = CircuitBreaker(
|
||||
settings.bitrix_local_app_circuit_failure_threshold,
|
||||
settings.bitrix_local_app_circuit_open_sec,
|
||||
)
|
||||
|
||||
async def send(
|
||||
self, message_id: uuid.UUID, payload: dict[str, Any], request_id: str
|
||||
) -> dict[str, Any]:
|
||||
if not self.breaker.allow():
|
||||
raise DependencyFailure()
|
||||
try:
|
||||
response = await self.http.post(
|
||||
f"{str(self.settings.bitrix_local_app_base_url).rstrip('/')}"
|
||||
"/internal/openlines/v1/messages",
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": "Bearer "
|
||||
+ self.settings.bitrix_local_app_internal_token.get_secret_value(),
|
||||
"Idempotency-Key": str(message_id),
|
||||
"X-Request-ID": request_id,
|
||||
},
|
||||
timeout=self.settings.bitrix_local_app_http_timeout_sec,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.TimeoutException as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure(timeout=True) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
self.breaker.failure()
|
||||
raise DependencyFailure() from exc
|
||||
self.breaker.success()
|
||||
return response.json()
|
||||
|
||||
async def ready(self) -> bool:
|
||||
try:
|
||||
response = await self.http.get(
|
||||
f"{str(self.settings.bitrix_local_app_base_url).rstrip('/')}"
|
||||
"/internal/openlines/v1/status",
|
||||
headers={
|
||||
"Authorization": "Bearer "
|
||||
+ self.settings.bitrix_local_app_internal_token.get_secret_value()
|
||||
},
|
||||
timeout=2,
|
||||
)
|
||||
return response.is_success
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
|
||||
|
||||
async def fresh_openlines_payload(
|
||||
payload: dict[str, Any], s3: "S3Client"
|
||||
) -> dict[str, Any]:
|
||||
result = json.loads(json.dumps(payload, default=str))
|
||||
for file in result.get("message", {}).get("files", []):
|
||||
bucket = file.pop("_storage_bucket")
|
||||
key = file.pop("_object_key")
|
||||
file["download_url"] = await s3.presign_get(bucket, key)
|
||||
return result
|
||||
|
||||
|
||||
class S3Client:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
self.client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=str(settings.selectel_s3_endpoint_url),
|
||||
aws_access_key_id=settings.selectel_s3_access_key.get_secret_value(),
|
||||
aws_secret_access_key=settings.selectel_s3_secret_key.get_secret_value(),
|
||||
config=Config(
|
||||
signature_version="s3v4",
|
||||
connect_timeout=3,
|
||||
read_timeout=10,
|
||||
retries={"max_attempts": 2},
|
||||
s3={"addressing_style": "virtual"},
|
||||
),
|
||||
)
|
||||
|
||||
async def ready(self) -> bool:
|
||||
try:
|
||||
for bucket in (
|
||||
self.settings.selectel_s3_bucket_quarantine,
|
||||
self.settings.selectel_s3_bucket_attachments,
|
||||
self.settings.selectel_s3_bucket_documents,
|
||||
):
|
||||
await asyncio.to_thread(self.client.head_bucket, Bucket=bucket)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def presign_put(self, key: str, mime: str, ttl: int) -> str:
|
||||
return await asyncio.to_thread(
|
||||
self.client.generate_presigned_url,
|
||||
"put_object",
|
||||
Params={
|
||||
"Bucket": self.settings.selectel_s3_bucket_quarantine,
|
||||
"Key": key,
|
||||
"ContentType": mime,
|
||||
},
|
||||
ExpiresIn=ttl,
|
||||
)
|
||||
|
||||
async def presign_get(self, bucket: str, key: str, ttl: int = 300) -> str:
|
||||
return await asyncio.to_thread(
|
||||
self.client.generate_presigned_url,
|
||||
"get_object",
|
||||
Params={"Bucket": bucket, "Key": key},
|
||||
ExpiresIn=ttl,
|
||||
)
|
||||
|
||||
async def head(self, bucket: str, key: str) -> dict[str, Any]:
|
||||
return await asyncio.to_thread(self.client.head_object, Bucket=bucket, Key=key)
|
||||
|
||||
async def promote(
|
||||
self,
|
||||
source_key: str,
|
||||
destination_key: str,
|
||||
*,
|
||||
version_id: str,
|
||||
etag: str,
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
self.client.copy_object,
|
||||
Bucket=self.settings.selectel_s3_bucket_attachments,
|
||||
Key=destination_key,
|
||||
CopySource={
|
||||
"Bucket": self.settings.selectel_s3_bucket_quarantine,
|
||||
"Key": source_key,
|
||||
"VersionId": version_id,
|
||||
},
|
||||
CopySourceIfMatch=etag,
|
||||
)
|
||||
# Keep the immutable source version until quarantine lifecycle expiry.
|
||||
# A crash after copy but before the DB checkpoint can then safely retry
|
||||
# the same conditional copy without losing its source.
|
||||
|
||||
async def delete_quarantine(self, key: str) -> None:
|
||||
await asyncio.to_thread(
|
||||
self.client.delete_object,
|
||||
Bucket=self.settings.selectel_s3_bucket_quarantine,
|
||||
Key=key,
|
||||
)
|
||||
|
||||
async def delete(self, bucket: str, key: str) -> None:
|
||||
await asyncio.to_thread(self.client.delete_object, Bucket=bucket, Key=key)
|
||||
|
||||
async def upload_inbound(
|
||||
self,
|
||||
http: httpx.AsyncClient,
|
||||
download_url: str,
|
||||
destination_key: str,
|
||||
mime_type: str,
|
||||
max_bytes: int,
|
||||
) -> tuple[int, str]:
|
||||
parsed = urlparse(download_url)
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
raise DependencyFailure("unsafe_inbound_url")
|
||||
try:
|
||||
addresses = await asyncio.to_thread(
|
||||
socket.getaddrinfo, parsed.hostname, parsed.port or 443, type=socket.SOCK_STREAM
|
||||
)
|
||||
except OSError as exc:
|
||||
raise DependencyFailure("unsafe_inbound_url") from exc
|
||||
if any(
|
||||
ipaddress.ip_address(address[4][0]).is_private
|
||||
or ipaddress.ip_address(address[4][0]).is_loopback
|
||||
or ipaddress.ip_address(address[4][0]).is_link_local
|
||||
or ipaddress.ip_address(address[4][0]).is_reserved
|
||||
for address in addresses
|
||||
):
|
||||
raise DependencyFailure("unsafe_inbound_url")
|
||||
data = bytearray()
|
||||
try:
|
||||
async with http.stream(
|
||||
"GET", download_url, timeout=10, follow_redirects=False
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
if response.headers.get("content-type", "").split(";")[0] != mime_type:
|
||||
raise DependencyFailure("inbound_mime_mismatch")
|
||||
async for chunk in response.aiter_bytes():
|
||||
data.extend(chunk)
|
||||
if len(data) > max_bytes:
|
||||
raise DependencyFailure("inbound_file_too_large")
|
||||
except httpx.HTTPError as exc:
|
||||
raise DependencyFailure() from exc
|
||||
await asyncio.to_thread(
|
||||
self.client.put_object,
|
||||
Bucket=self.settings.selectel_s3_bucket_attachments,
|
||||
Key=destination_key,
|
||||
Body=bytes(data),
|
||||
ContentType=mime_type,
|
||||
)
|
||||
return len(data), hashlib.sha256(data).hexdigest()
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
REDACTED = "[REDACTED]"
|
||||
_SENSITIVE_KEY = re.compile(
|
||||
r"(authorization|cookie|password|passwd|secret|token|api[_-]?key|"
|
||||
r"database[_-]?url|redis[_-]?url|dsn|callback[_-]?url)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_URI_USERINFO = re.compile(r"(?P<scheme>[a-z][a-z0-9+.-]*://)[^/@\s]+@", re.IGNORECASE)
|
||||
_QUERY_SECRET = re.compile(
|
||||
r"(?P<prefix>[?&](?:token|access_token|api_key|key|secret|password)=)[^&#\s]+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_AUTH_VALUE = re.compile(r"\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE)
|
||||
|
||||
|
||||
def sanitize_text(value: str) -> str:
|
||||
value = _URI_USERINFO.sub(r"\g<scheme>[REDACTED]@", value)
|
||||
value = _QUERY_SECRET.sub(r"\g<prefix>[REDACTED]", value)
|
||||
return _AUTH_VALUE.sub(r"\1 [REDACTED]", value)
|
||||
|
||||
|
||||
def sanitize_value(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return sanitize_text(value)
|
||||
if isinstance(value, Mapping):
|
||||
return {
|
||||
str(key): REDACTED if _SENSITIVE_KEY.search(str(key)) else sanitize_value(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [sanitize_value(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return tuple(sanitize_value(item) for item in value)
|
||||
return value
|
||||
|
||||
|
||||
def redact_event(
|
||||
_logger: Any,
|
||||
_method_name: str,
|
||||
event_dict: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return sanitize_value(event_dict)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
from opentelemetry import metrics
|
||||
|
||||
meter = metrics.get_meter("han.api")
|
||||
|
||||
HTTP_REQUESTS = meter.create_counter(
|
||||
"han_http_requests_total",
|
||||
description="Completed HTTP requests",
|
||||
)
|
||||
HTTP_DURATION = meter.create_histogram(
|
||||
"han_http_request_duration_seconds",
|
||||
unit="s",
|
||||
description="HTTP request duration",
|
||||
)
|
||||
AUTH_BOOTSTRAP = meter.create_counter(
|
||||
"han_auth_bootstrap_total",
|
||||
description="Authentication bootstrap outcomes",
|
||||
)
|
||||
RATE_LIMIT_DECISIONS = meter.create_counter(
|
||||
"han_rate_limit_decisions_total",
|
||||
description="Rate-limit decisions",
|
||||
)
|
||||
@@ -0,0 +1,303 @@
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
ARRAY,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Numeric,
|
||||
SmallInteger,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import SCHEMA, Base, Common
|
||||
|
||||
|
||||
def uuid7() -> uuid.UUID:
|
||||
"""Generate an RFC 9562 UUIDv7 without relying on Python 3.14."""
|
||||
timestamp_ms = int(time.time_ns() // 1_000_000) & ((1 << 48) - 1)
|
||||
value = timestamp_ms << 80
|
||||
value |= 0x7 << 76
|
||||
value |= secrets.randbits(12) << 64
|
||||
value |= 0b10 << 62
|
||||
value |= secrets.randbits(62)
|
||||
return uuid.UUID(int=value)
|
||||
|
||||
|
||||
class NotificationCtaAction(Common, Base):
|
||||
__tablename__ = "notification_cta_actions"
|
||||
__table_args__ = (UniqueConstraint("code"), {"schema": SCHEMA})
|
||||
code: Mapped[str] = mapped_column(String(32))
|
||||
description: Mapped[str] = mapped_column(String(255))
|
||||
requires_auth: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
required_instance_fields: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(String(64)), default=list, server_default="{}"
|
||||
)
|
||||
|
||||
|
||||
class NotificationButton(Common, Base):
|
||||
__tablename__ = "notification_buttons"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("code"),
|
||||
CheckConstraint("NOT applies_hidden_ttl OR sets_hidden"),
|
||||
CheckConstraint("NOT submits_documents OR close_reason IS NOT NULL"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
code: Mapped[str] = mapped_column(String(32))
|
||||
label: Mapped[str] = mapped_column(String(64))
|
||||
sets_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
applies_hidden_ttl: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
close_reason: Mapped[str | None] = mapped_column(String(32))
|
||||
submits_documents: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class NotificationColorToken(Common, Base):
|
||||
__tablename__ = "notification_color_tokens"
|
||||
__table_args__ = (UniqueConstraint("code"), {"schema": SCHEMA})
|
||||
code: Mapped[str] = mapped_column(String(32))
|
||||
description: Mapped[str] = mapped_column(String(255))
|
||||
sort_order: Mapped[int] = mapped_column(SmallInteger)
|
||||
|
||||
|
||||
class NotificationType(Common, Base):
|
||||
__tablename__ = "notification_types"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("code"),
|
||||
CheckConstraint("contour IN ('G','P')"),
|
||||
CheckConstraint("contour <> 'G' OR countable = false"),
|
||||
CheckConstraint("contour <> 'G' OR cta_action <> 'open_detail'"),
|
||||
CheckConstraint(
|
||||
"cta_action <> 'open_detail' OR (cta_sets_hidden = false AND cta_close_reason IS NULL)"
|
||||
),
|
||||
CheckConstraint(
|
||||
"cta_action = 'open_detail' OR "
|
||||
"(documents_allowed = false AND hide_on_document_download = false "
|
||||
"AND required_detail_blocks = '{}')"
|
||||
),
|
||||
CheckConstraint("NOT hide_on_document_download OR documents_allowed"),
|
||||
CheckConstraint("cta_action <> 'open_detail' OR button_primary_code IS NOT NULL"),
|
||||
CheckConstraint(
|
||||
"cta_action = 'open_detail' OR "
|
||||
"(button_primary_code IS NULL AND button_secondary_code IS NULL)"
|
||||
),
|
||||
CheckConstraint("button_secondary_code IS NULL OR button_primary_code IS NOT NULL"),
|
||||
CheckConstraint(
|
||||
"button_secondary_code IS NULL OR button_secondary_code <> button_primary_code"
|
||||
),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
code: Mapped[str] = mapped_column(String(32))
|
||||
contour: Mapped[str] = mapped_column(String(1))
|
||||
priority: Mapped[int] = mapped_column(SmallInteger)
|
||||
countable: Mapped[bool] = mapped_column(Boolean)
|
||||
label: Mapped[str] = mapped_column(String(64))
|
||||
color_token: Mapped[str] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notification_color_tokens.code", ondelete="RESTRICT")
|
||||
)
|
||||
icon_code: Mapped[str | None] = mapped_column(String(32))
|
||||
cta_text: Mapped[str] = mapped_column(String(64))
|
||||
cta_action: Mapped[str] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notification_cta_actions.code", ondelete="RESTRICT")
|
||||
)
|
||||
cta_sets_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
cta_close_reason: Mapped[str | None] = mapped_column(String(32))
|
||||
button_primary_code: Mapped[str | None] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notification_buttons.code", ondelete="RESTRICT")
|
||||
)
|
||||
button_secondary_code: Mapped[str | None] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notification_buttons.code", ondelete="RESTRICT")
|
||||
)
|
||||
hidden_ttl_days: Mapped[int | None] = mapped_column(SmallInteger)
|
||||
documents_allowed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
hide_on_document_download: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
required_detail_blocks: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(String(64)), default=list, server_default="{}"
|
||||
)
|
||||
|
||||
|
||||
class NotificationSource(Common, Base):
|
||||
__tablename__ = "notification_sources"
|
||||
__table_args__ = (UniqueConstraint("code"), UniqueConstraint("token_hash"), {"schema": SCHEMA})
|
||||
code: Mapped[str] = mapped_column(String(32))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
token_hash: Mapped[str] = mapped_column(String(128))
|
||||
token_rotated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Notification(Common, Base):
|
||||
__tablename__ = "notifications"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source", "external_id", name="uq_notifications_source_key"),
|
||||
CheckConstraint("lifecycle_status IN ('active','closed')"),
|
||||
CheckConstraint("visibility IN ('visible','hidden')"),
|
||||
CheckConstraint(
|
||||
"close_reason IS NULL OR close_reason IN "
|
||||
"('user_done','docs_submitted','offer_accepted','paid','expired','cancelled')"
|
||||
),
|
||||
CheckConstraint(
|
||||
"lifecycle_status <> 'closed' OR (close_reason IS NOT NULL AND closed_at IS NOT NULL)"
|
||||
),
|
||||
CheckConstraint("old_price IS NULL OR price IS NOT NULL"),
|
||||
Index(
|
||||
"ix_notifications_user_active",
|
||||
"user_id",
|
||||
"lifecycle_status",
|
||||
"visibility",
|
||||
"notification_datetime",
|
||||
"id",
|
||||
),
|
||||
Index("ix_notifications_expire", "date_expired"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid7)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
|
||||
)
|
||||
notification_type: Mapped[str] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notification_types.code", ondelete="RESTRICT")
|
||||
)
|
||||
source: Mapped[str] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notification_sources.code", ondelete="RESTRICT")
|
||||
)
|
||||
external_id: Mapped[str] = mapped_column(String(128))
|
||||
request_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||
notification_datetime: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
header: Mapped[str] = mapped_column(String(255))
|
||||
text: Mapped[str | None] = mapped_column(String(1024))
|
||||
priority_override: Mapped[int | None] = mapped_column(SmallInteger)
|
||||
date_expired: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
old_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
payment_url: Mapped[str | None] = mapped_column(Text)
|
||||
details: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
||||
details_schema_version: Mapped[int] = mapped_column(SmallInteger, default=1)
|
||||
chat_message_text: Mapped[str | None] = mapped_column(String(1024))
|
||||
lifecycle_status: Mapped[str] = mapped_column(String(16), default="active")
|
||||
visibility: Mapped[str] = mapped_column(String(16), default="visible")
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
close_reason: Mapped[str | None] = mapped_column(String(32))
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class GuestNotification(Common, Base):
|
||||
__tablename__ = "guest_notifications"
|
||||
__table_args__ = (
|
||||
CheckConstraint("lifecycle_status IN ('active','closed')"),
|
||||
CheckConstraint("old_price IS NULL OR price IS NOT NULL"),
|
||||
CheckConstraint("instruction_url IS NULL OR instruction_url LIKE 'https://%'"),
|
||||
Index("ix_guest_notifications_active", "lifecycle_status", "notification_datetime", "id"),
|
||||
Index("ix_guest_notifications_expire", "date_expired"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid7)
|
||||
notification_type: Mapped[str] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notification_types.code", ondelete="RESTRICT")
|
||||
)
|
||||
notification_datetime: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
header: Mapped[str] = mapped_column(String(255))
|
||||
text: Mapped[str | None] = mapped_column(String(1024))
|
||||
priority_override: Mapped[int | None] = mapped_column(SmallInteger)
|
||||
date_expired: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
old_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
instruction_url: Mapped[str | None] = mapped_column(Text)
|
||||
chat_message_text: Mapped[str | None] = mapped_column(String(1024))
|
||||
lifecycle_status: Mapped[str] = mapped_column(String(16), default="active")
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class NotificationDocument(Common, Base):
|
||||
__tablename__ = "notification_documents"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("notification_id", "document_id"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
notification_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notifications.id", ondelete="RESTRICT")
|
||||
)
|
||||
document_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.documents.id", ondelete="RESTRICT")
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||
download_url_issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class ClientUploadDraft(Base):
|
||||
__tablename__ = "client_upload_drafts"
|
||||
__table_args__ = (
|
||||
CheckConstraint("context_type IN ('notification')"),
|
||||
CheckConstraint("size_bytes > 0"),
|
||||
CheckConstraint("scan_status IN ('pending','clean','bypassed','infected','failed')"),
|
||||
CheckConstraint("state IN ('draft','submitted','discarded')"),
|
||||
Index("ix_client_upload_drafts_context", "user_id", "context_type", "context_id"),
|
||||
Index("ix_client_upload_drafts_scan", "scan_status", "updated_at"),
|
||||
Index("ix_client_upload_drafts_created", "created_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid7)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
|
||||
)
|
||||
context_type: Mapped[str] = mapped_column(String(32))
|
||||
context_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
original_file_name: Mapped[str] = mapped_column(String(255))
|
||||
safe_file_name: Mapped[str] = mapped_column(String(255))
|
||||
mime_type: Mapped[str] = mapped_column(String(128))
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||
checksum_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||
scan_status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||
storage_bucket: Mapped[str] = mapped_column(String(255))
|
||||
object_key: Mapped[str] = mapped_column(String(1024))
|
||||
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
|
||||
quarantine_version_id: Mapped[str | None] = mapped_column(String(1024))
|
||||
quarantine_etag: Mapped[str | None] = mapped_column(String(1024))
|
||||
safety_processing_mode: Mapped[str | None] = mapped_column(String(16))
|
||||
safety_config_version: Mapped[int | None] = mapped_column(BigInteger)
|
||||
safety_rules_version: Mapped[str | None] = mapped_column(String(128))
|
||||
upload_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
state: Mapped[str] = mapped_column(String(16), default="draft")
|
||||
submission_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class ClientDocument(Common, Base):
|
||||
__tablename__ = "client_documents"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("storage_bucket", "object_key"),
|
||||
UniqueConstraint("source_draft_id"),
|
||||
Index("ix_client_documents_context", "context_type", "context_id"),
|
||||
Index("ix_client_documents_user_submitted", "user_id", "submitted_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
|
||||
)
|
||||
context_type: Mapped[str] = mapped_column(String(32))
|
||||
context_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
submission_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
source_draft_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
original_file_name: Mapped[str] = mapped_column(String(255))
|
||||
safe_file_name: Mapped[str] = mapped_column(String(255))
|
||||
mime_type: Mapped[str] = mapped_column(String(128))
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||
checksum_sha256: Mapped[str] = mapped_column(String(64))
|
||||
storage_bucket: Mapped[str] = mapped_column(String(255))
|
||||
object_key: Mapped[str] = mapped_column(String(1024))
|
||||
submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
@@ -0,0 +1,505 @@
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import AuthError
|
||||
from app.db import UserIdentity
|
||||
from app.notification_models import NotificationSource
|
||||
from app.notification_schemas import (
|
||||
NotificationCancelRequest,
|
||||
NotificationCreateRequest,
|
||||
UploadCompleteRequest,
|
||||
UploadInitRequest,
|
||||
)
|
||||
from app.notification_service import (
|
||||
apply_read_or_hide,
|
||||
authenticate_source,
|
||||
cancel_notification,
|
||||
catalog,
|
||||
complete_upload,
|
||||
create_notification,
|
||||
discard_upload,
|
||||
document_download,
|
||||
init_upload,
|
||||
invoke_cta_state,
|
||||
list_notifications,
|
||||
list_uploads,
|
||||
notification_dto,
|
||||
owned_notification,
|
||||
press_button,
|
||||
public_notifications,
|
||||
unread_count,
|
||||
)
|
||||
from app.schemas import TextMessageRequest
|
||||
from app.services import (
|
||||
AuditContext,
|
||||
DomainError,
|
||||
SettingsSnapshot,
|
||||
create_dialog,
|
||||
load_settings,
|
||||
resolve_user,
|
||||
send_message,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def db_session(request: Request):
|
||||
async for value in request.app.state.db.session():
|
||||
yield value
|
||||
|
||||
|
||||
Session = Annotated[AsyncSession, Depends(db_session)]
|
||||
|
||||
|
||||
async def user_dependency(
|
||||
request: Request,
|
||||
db: Session,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
) -> UserIdentity:
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise AuthError()
|
||||
principal = await request.app.state.jwks.validate(authorization.removeprefix("Bearer ").strip())
|
||||
return await resolve_user(db, principal)
|
||||
|
||||
|
||||
User = Annotated[UserIdentity, Depends(user_dependency)]
|
||||
|
||||
|
||||
async def snapshot_dependency(db: Session) -> SettingsSnapshot:
|
||||
return await load_settings(db)
|
||||
|
||||
|
||||
Snapshot = Annotated[SettingsSnapshot, Depends(snapshot_dependency)]
|
||||
|
||||
|
||||
async def source_dependency(
|
||||
db: Session, authorization: Annotated[str | None, Header()] = None
|
||||
) -> NotificationSource:
|
||||
return await authenticate_source(db, authorization)
|
||||
|
||||
|
||||
Source = Annotated[NotificationSource, Depends(source_dependency)]
|
||||
|
||||
|
||||
def context(request: Request, ux_session: str | None = None) -> AuditContext:
|
||||
try:
|
||||
ux_id = uuid.UUID(ux_session) if ux_session else None
|
||||
except ValueError:
|
||||
raise DomainError("validation_error", 400, "X-Ux-Session-Id must be UUID") from None
|
||||
return AuditContext(
|
||||
request_id=request.state.request_id,
|
||||
trace_id=request.state.trace_id,
|
||||
ux_session_id=ux_id,
|
||||
user_agent_hash=request.state.user_agent_hash,
|
||||
client_ip=None,
|
||||
)
|
||||
|
||||
|
||||
async def rate_limit(
|
||||
request: Request,
|
||||
identity: str,
|
||||
route: str,
|
||||
limit: tuple[int, int],
|
||||
*,
|
||||
fail_closed: bool,
|
||||
) -> None:
|
||||
key = request.app.state.rate_limiter.key("notification", identity, route, limit[1])
|
||||
try:
|
||||
retry_after = await request.app.state.rate_limiter.consume(key, *limit)
|
||||
except Exception:
|
||||
if fail_closed:
|
||||
raise DomainError(
|
||||
"dependency_unavailable", 503, "Rate limit service is unavailable"
|
||||
) from None
|
||||
return
|
||||
if retry_after:
|
||||
raise DomainError(
|
||||
"rate_limit_exceeded",
|
||||
429,
|
||||
"Rate limit exceeded",
|
||||
{"retry_after": retry_after},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/public/notifications", tags=["notifications"])
|
||||
async def public_list(request: Request, db: Session, settings: Snapshot):
|
||||
await rate_limit(
|
||||
request,
|
||||
request.client.host if request.client else "unknown",
|
||||
"public",
|
||||
settings.limit("rate_limit.notifications_public.per_ip"),
|
||||
fail_closed=False,
|
||||
)
|
||||
return {
|
||||
"items": await public_notifications(db, settings.integer("notification.home.max_items"))
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v1/public/notification-types", tags=["notifications"])
|
||||
async def type_catalog(
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session,
|
||||
settings: Snapshot,
|
||||
if_none_match: Annotated[str | None, Header()] = None,
|
||||
):
|
||||
await rate_limit(
|
||||
request,
|
||||
request.client.host if request.client else "unknown",
|
||||
"public",
|
||||
settings.limit("rate_limit.notifications_public.per_ip"),
|
||||
fail_closed=False,
|
||||
)
|
||||
items = await catalog(db)
|
||||
digest = hashlib.sha256(
|
||||
json.dumps(items, default=str, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
etag = f'"{digest}"'
|
||||
if if_none_match == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag})
|
||||
response.headers["ETag"] = etag
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/api/v1/notifications", tags=["notifications"])
|
||||
async def personal_list(
|
||||
request: Request,
|
||||
db: Session,
|
||||
user: User,
|
||||
settings: Snapshot,
|
||||
place: str = Query(pattern="^(home|center)$"),
|
||||
):
|
||||
await rate_limit(
|
||||
request,
|
||||
str(user.id),
|
||||
"read",
|
||||
settings.limit("rate_limit.notifications_read.per_user"),
|
||||
fail_closed=False,
|
||||
)
|
||||
limit = settings.integer(
|
||||
"notification.home.max_items" if place == "home" else "notification.center.max_items"
|
||||
)
|
||||
return {"items": await list_notifications(db, user.id, place, limit)}
|
||||
|
||||
|
||||
@router.get("/api/v1/notifications/counter", tags=["notifications"])
|
||||
async def counter(request: Request, db: Session, user: User, settings: Snapshot):
|
||||
await rate_limit(
|
||||
request,
|
||||
str(user.id),
|
||||
"read",
|
||||
settings.limit("rate_limit.notifications_read.per_user"),
|
||||
fail_closed=False,
|
||||
)
|
||||
return {
|
||||
"unread_count": await unread_count(
|
||||
db, user.id, settings.integer("notification.center.max_items")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v1/notifications/{notification_id}", tags=["notifications"])
|
||||
async def detail(
|
||||
notification_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: Session,
|
||||
user: User,
|
||||
settings: Snapshot,
|
||||
):
|
||||
await rate_limit(
|
||||
request,
|
||||
str(user.id),
|
||||
"read",
|
||||
settings.limit("rate_limit.notifications_read.per_user"),
|
||||
fail_closed=False,
|
||||
)
|
||||
item, kind = await owned_notification(db, user.id, notification_id)
|
||||
if kind.cta_action != "open_detail":
|
||||
raise DomainError("not_found", 404, "Resource was not found")
|
||||
return await notification_dto(db, item, kind)
|
||||
|
||||
|
||||
@router.post("/api/v1/notifications/{notification_id}/read", tags=["notifications"])
|
||||
async def mark_read(
|
||||
notification_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: Session,
|
||||
user: User,
|
||||
settings: Snapshot,
|
||||
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
|
||||
):
|
||||
await rate_limit(
|
||||
request,
|
||||
str(user.id),
|
||||
"action",
|
||||
settings.limit("rate_limit.notifications_action.per_user"),
|
||||
fail_closed=False,
|
||||
)
|
||||
return await apply_read_or_hide(
|
||||
db,
|
||||
user.id,
|
||||
notification_id,
|
||||
"read",
|
||||
settings,
|
||||
request.app.state.realtime,
|
||||
context(request, x_ux_session_id),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/notifications/{notification_id}/hide", tags=["notifications"])
|
||||
async def hide(
|
||||
notification_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: Session,
|
||||
user: User,
|
||||
settings: Snapshot,
|
||||
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
|
||||
):
|
||||
await rate_limit(
|
||||
request,
|
||||
str(user.id),
|
||||
"action",
|
||||
settings.limit("rate_limit.notifications_action.per_user"),
|
||||
fail_closed=False,
|
||||
)
|
||||
return await apply_read_or_hide(
|
||||
db,
|
||||
user.id,
|
||||
notification_id,
|
||||
"hide",
|
||||
settings,
|
||||
request.app.state.realtime,
|
||||
context(request, x_ux_session_id),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/notifications/{notification_id}/buttons/{button_code}",
|
||||
tags=["notifications"],
|
||||
)
|
||||
async def button(
|
||||
notification_id: uuid.UUID,
|
||||
button_code: str,
|
||||
request: Request,
|
||||
db: Session,
|
||||
user: User,
|
||||
settings: Snapshot,
|
||||
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
|
||||
):
|
||||
await rate_limit(
|
||||
request,
|
||||
str(user.id),
|
||||
"action",
|
||||
settings.limit("rate_limit.notifications_action.per_user"),
|
||||
fail_closed=False,
|
||||
)
|
||||
return await press_button(
|
||||
db,
|
||||
user.id,
|
||||
notification_id,
|
||||
button_code,
|
||||
settings,
|
||||
request.app.state.realtime,
|
||||
context(request, x_ux_session_id),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/notifications/{notification_id}/cta", tags=["notifications"])
|
||||
async def cta(
|
||||
notification_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: Session,
|
||||
user: User,
|
||||
settings: Snapshot,
|
||||
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
|
||||
):
|
||||
await rate_limit(
|
||||
request,
|
||||
str(user.id),
|
||||
"action",
|
||||
settings.limit("rate_limit.notifications_action.per_user"),
|
||||
fail_closed=False,
|
||||
)
|
||||
audit_context = context(request, x_ux_session_id)
|
||||
item, kind = await owned_notification(db, user.id, notification_id, action=True)
|
||||
chat_result = None
|
||||
if kind.cta_action == "send_chat_message":
|
||||
dialog, _status = await create_dialog(
|
||||
db, user, audit_context, f"notification-dialog:{item.id}"
|
||||
)
|
||||
chat_result = await send_message(
|
||||
db,
|
||||
user,
|
||||
uuid.UUID(str(dialog["dialog_id"])),
|
||||
TextMessageRequest(content_kind="text", text=item.chat_message_text or ""),
|
||||
f"notification-message:{item.id}",
|
||||
audit_context,
|
||||
settings,
|
||||
request.app.state.settings,
|
||||
request.app.state.safety,
|
||||
request.app.state.openlines,
|
||||
request.app.state.s3,
|
||||
request.app.state.realtime,
|
||||
)
|
||||
_item, _kind, result = await invoke_cta_state(
|
||||
db,
|
||||
user.id,
|
||||
notification_id,
|
||||
settings,
|
||||
request.app.state.realtime,
|
||||
audit_context,
|
||||
chat_result=chat_result,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/notifications/{notification_id}/documents/{document_id}/download-url",
|
||||
tags=["notifications"],
|
||||
)
|
||||
async def download(
|
||||
notification_id: uuid.UUID,
|
||||
document_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: Session,
|
||||
user: User,
|
||||
settings: Snapshot,
|
||||
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
|
||||
):
|
||||
await rate_limit(
|
||||
request,
|
||||
str(user.id),
|
||||
"download",
|
||||
settings.limit("rate_limit.download_url.per_user"),
|
||||
fail_closed=True,
|
||||
)
|
||||
return await document_download(
|
||||
db,
|
||||
user.id,
|
||||
notification_id,
|
||||
document_id,
|
||||
settings,
|
||||
request.app.state.s3,
|
||||
request.app.state.realtime,
|
||||
context(request, x_ux_session_id),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/uploads/init", status_code=201, tags=["uploads"])
|
||||
async def upload_init(
|
||||
body: UploadInitRequest,
|
||||
request: Request,
|
||||
db: Session,
|
||||
user: User,
|
||||
settings: Snapshot,
|
||||
):
|
||||
await rate_limit(
|
||||
request,
|
||||
str(user.id),
|
||||
"upload",
|
||||
settings.limit("rate_limit.notification_upload.per_user"),
|
||||
fail_closed=True,
|
||||
)
|
||||
return await init_upload(db, user.id, body, settings, request.app.state.s3)
|
||||
|
||||
|
||||
@router.post("/api/v1/uploads/{draft_id}/complete", tags=["uploads"])
|
||||
async def upload_complete(
|
||||
draft_id: uuid.UUID,
|
||||
body: UploadCompleteRequest,
|
||||
request: Request,
|
||||
db: Session,
|
||||
user: User,
|
||||
settings: Snapshot,
|
||||
):
|
||||
await rate_limit(
|
||||
request,
|
||||
str(user.id),
|
||||
"upload",
|
||||
settings.limit("rate_limit.notification_upload.per_user"),
|
||||
fail_closed=True,
|
||||
)
|
||||
return await complete_upload(
|
||||
db,
|
||||
user.id,
|
||||
draft_id,
|
||||
body,
|
||||
request.app.state.s3,
|
||||
request.app.state.safety,
|
||||
request.state.request_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/uploads", tags=["uploads"])
|
||||
async def uploads(
|
||||
request: Request,
|
||||
db: Session,
|
||||
user: User,
|
||||
settings: Snapshot,
|
||||
context_type: str,
|
||||
context_id: uuid.UUID,
|
||||
):
|
||||
await rate_limit(
|
||||
request,
|
||||
str(user.id),
|
||||
"upload",
|
||||
settings.limit("rate_limit.notification_upload.per_user"),
|
||||
fail_closed=True,
|
||||
)
|
||||
if context_type != "notification":
|
||||
raise DomainError("validation_error", 400, "Unsupported upload context")
|
||||
return {"items": await list_uploads(db, user.id, context_type, context_id)}
|
||||
|
||||
|
||||
@router.delete("/api/v1/uploads/{draft_id}", status_code=204, tags=["uploads"])
|
||||
async def upload_delete(
|
||||
draft_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: Session,
|
||||
user: User,
|
||||
settings: Snapshot,
|
||||
):
|
||||
await rate_limit(
|
||||
request,
|
||||
str(user.id),
|
||||
"upload",
|
||||
settings.limit("rate_limit.notification_upload.per_user"),
|
||||
fail_closed=True,
|
||||
)
|
||||
await discard_upload(db, user.id, draft_id, request.app.state.s3)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/internal/notifications/v1/notifications", tags=["internal"])
|
||||
async def internal_create(
|
||||
body: NotificationCreateRequest,
|
||||
request: Request,
|
||||
db: Session,
|
||||
source: Source,
|
||||
):
|
||||
result, status = await create_notification(
|
||||
db,
|
||||
body,
|
||||
source,
|
||||
request.app.state.s3,
|
||||
request.app.state.realtime,
|
||||
context(request),
|
||||
)
|
||||
return JSONResponse(json.loads(json.dumps(result, default=str)), status_code=status)
|
||||
|
||||
|
||||
@router.post("/internal/notifications/v1/notifications/cancel", tags=["internal"])
|
||||
async def internal_cancel(
|
||||
body: NotificationCancelRequest,
|
||||
request: Request,
|
||||
db: Session,
|
||||
source: Source,
|
||||
):
|
||||
return await cancel_notification(db, body, source, request.app.state.realtime, context(request))
|
||||
@@ -0,0 +1,72 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, HttpUrl, model_validator
|
||||
|
||||
from app.schemas import StrictModel
|
||||
|
||||
|
||||
class TodoItem(StrictModel):
|
||||
number: int
|
||||
text: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class CompanyDocumentInput(StrictModel):
|
||||
object_key: str = Field(min_length=1, max_length=1024)
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
mime_type: str = Field(min_length=1, max_length=128)
|
||||
size_bytes: int = Field(gt=0)
|
||||
checksum_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class NotificationDetailsInput(StrictModel):
|
||||
deadline: datetime | None = None
|
||||
details_header: str | None = Field(default=None, max_length=255)
|
||||
details_text: str | None = Field(default=None, max_length=4000)
|
||||
todo_header: str | None = Field(default=None, max_length=255)
|
||||
todo_plan: list[TodoItem] | None = Field(default=None, min_length=1)
|
||||
send_documents: bool = False
|
||||
documents: list[CompanyDocumentInput] | None = Field(default=None, min_length=1)
|
||||
|
||||
|
||||
class NotificationCreateRequest(StrictModel):
|
||||
user_id: uuid.UUID
|
||||
notification_type: str = Field(min_length=1, max_length=32)
|
||||
source: str = Field(min_length=1, max_length=32)
|
||||
external_id: str = Field(min_length=1, max_length=128)
|
||||
notification_datetime: datetime
|
||||
header: str = Field(min_length=1, max_length=255)
|
||||
text: str | None = Field(default=None, max_length=1024)
|
||||
priority_override: int | None = None
|
||||
date_expired: datetime | None = None
|
||||
price: Decimal | None = Field(default=None, ge=0, max_digits=12, decimal_places=2)
|
||||
old_price: Decimal | None = Field(default=None, ge=0, max_digits=12, decimal_places=2)
|
||||
payment_url: HttpUrl | None = None
|
||||
chat_message_text: str | None = Field(default=None, max_length=1024)
|
||||
details: NotificationDetailsInput | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def prices(self) -> "NotificationCreateRequest":
|
||||
if self.old_price is not None and self.price is None:
|
||||
raise ValueError("old_price requires price")
|
||||
return self
|
||||
|
||||
|
||||
class NotificationCancelRequest(StrictModel):
|
||||
source: str = Field(min_length=1, max_length=32)
|
||||
external_id: str = Field(min_length=1, max_length=128)
|
||||
close_reason: Literal["cancelled", "paid"]
|
||||
|
||||
|
||||
class UploadInitRequest(StrictModel):
|
||||
context_type: Literal["notification"]
|
||||
context_id: uuid.UUID
|
||||
file_name: str = Field(min_length=1, max_length=255)
|
||||
mime_type: str = Field(min_length=1, max_length=128)
|
||||
size_bytes: int = Field(gt=0)
|
||||
|
||||
|
||||
class UploadCompleteRequest(StrictModel):
|
||||
checksum: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
from collections.abc import Mapping
|
||||
|
||||
OTP_SETTING_KEYS = {
|
||||
"otp.phone.max_send_attempts_per_24h",
|
||||
"otp.phone.min_seconds_between_attempts",
|
||||
"otp.phone.max_verify_attempts",
|
||||
"otp.phone.code_length",
|
||||
"otp.phone.ttl_seconds",
|
||||
"otp.phone.sms_order_timeout_ms",
|
||||
}
|
||||
|
||||
|
||||
def validate_otp_settings(values: Mapping[str, str]) -> None:
|
||||
parsed: dict[str, int] = {}
|
||||
for key in OTP_SETTING_KEYS:
|
||||
raw = values.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError(f"{key}: integer value expected") from error
|
||||
if str(value) != raw:
|
||||
raise ValueError(f"{key}: canonical integer value expected")
|
||||
parsed[key] = value
|
||||
|
||||
positive = OTP_SETTING_KEYS - {"otp.phone.min_seconds_between_attempts"}
|
||||
for key in positive:
|
||||
if key in parsed and parsed[key] <= 0:
|
||||
raise ValueError(f"{key}: value must be positive")
|
||||
if parsed.get("otp.phone.min_seconds_between_attempts", 0) < 0:
|
||||
raise ValueError("otp.phone.min_seconds_between_attempts: value must be non-negative")
|
||||
|
||||
code_length = parsed.get("otp.phone.code_length")
|
||||
if code_length is not None and not 4 <= code_length <= 10:
|
||||
raise ValueError("otp.phone.code_length: value must be between 4 and 10")
|
||||
|
||||
ttl_seconds = parsed.get("otp.phone.ttl_seconds")
|
||||
if ttl_seconds is not None and (
|
||||
not 60 <= ttl_seconds <= 900 or ttl_seconds % 60 != 0
|
||||
):
|
||||
raise ValueError(
|
||||
"otp.phone.ttl_seconds: value must be between 60 and 900 and divisible by 60"
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
DIALOG_CHANNEL_PREFIX = "han:rt:dialog:"
|
||||
USER_CHANNEL_PREFIX = "han:rt:user:"
|
||||
# Backward-compatible name used by existing chat integrations.
|
||||
CHANNEL_PREFIX = DIALOG_CHANNEL_PREFIX
|
||||
|
||||
|
||||
class LocalFanout:
|
||||
def __init__(self) -> None:
|
||||
self._queues: set[asyncio.Queue[dict[str, Any]]] = set()
|
||||
|
||||
async def publish(self, event: dict[str, Any]) -> None:
|
||||
for queue in tuple(self._queues):
|
||||
with suppress(asyncio.QueueFull):
|
||||
queue.put_nowait(event)
|
||||
|
||||
async def subscribe(self) -> AsyncIterator[dict[str, Any]]:
|
||||
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=256)
|
||||
self._queues.add(queue)
|
||||
try:
|
||||
while True:
|
||||
yield await queue.get()
|
||||
finally:
|
||||
self._queues.discard(queue)
|
||||
|
||||
|
||||
class RealtimeFanout:
|
||||
def __init__(self, redis: Redis, local: LocalFanout | None = None) -> None:
|
||||
self.redis = redis
|
||||
self.local = local or LocalFanout()
|
||||
|
||||
async def publish(self, event: dict[str, Any]) -> None:
|
||||
event = {"event_id": str(uuid.uuid4()), **event}
|
||||
channel = DIALOG_CHANNEL_PREFIX + str(event["dialog_id"])
|
||||
try:
|
||||
await self.redis.publish(channel, json.dumps(event, default=str, separators=(",", ":")))
|
||||
except Exception:
|
||||
await self.local.publish(event)
|
||||
|
||||
async def publish_user(self, user_id: uuid.UUID, event: dict[str, Any]) -> None:
|
||||
event = {"event_id": str(uuid.uuid4()), "_user_id": str(user_id), **event}
|
||||
channel = USER_CHANNEL_PREFIX + str(user_id)
|
||||
try:
|
||||
await self.redis.publish(channel, json.dumps(event, default=str, separators=(",", ":")))
|
||||
except Exception:
|
||||
await self.local.publish(event)
|
||||
|
||||
async def events(
|
||||
self,
|
||||
dialog_ids: set[uuid.UUID],
|
||||
user_id: uuid.UUID | None = None,
|
||||
notifications: bool = False,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
channels = [DIALOG_CHANNEL_PREFIX + str(dialog_id) for dialog_id in dialog_ids]
|
||||
if notifications and user_id is not None:
|
||||
channels.append(USER_CHANNEL_PREFIX + str(user_id))
|
||||
if not channels:
|
||||
await asyncio.Event().wait()
|
||||
return
|
||||
pubsub = self.redis.pubsub()
|
||||
try:
|
||||
await pubsub.subscribe(*channels)
|
||||
except Exception:
|
||||
await pubsub.aclose()
|
||||
async for event in self.local.subscribe():
|
||||
dialog_match = event.get("dialog_id") and uuid.UUID(
|
||||
str(event["dialog_id"])
|
||||
) in dialog_ids
|
||||
user_match = (
|
||||
notifications
|
||||
and user_id is not None
|
||||
and event.get("_user_id") == str(user_id)
|
||||
)
|
||||
if dialog_match or user_match:
|
||||
payload = dict(event)
|
||||
payload.pop("_user_id", None)
|
||||
yield payload
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1)
|
||||
if message:
|
||||
payload = json.loads(message["data"])
|
||||
payload.pop("_user_id", None)
|
||||
yield payload
|
||||
else:
|
||||
await asyncio.sleep(0)
|
||||
finally:
|
||||
await pubsub.unsubscribe(*channels)
|
||||
await pubsub.aclose()
|
||||
@@ -0,0 +1,161 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import uuid
|
||||
from base64 import urlsafe_b64decode, urlsafe_b64encode
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator
|
||||
|
||||
from app.chat_settings import CHAT_MESSAGE_TRANSPORT_MAX_LENGTH
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class OtpSettingsResponse(StrictModel):
|
||||
max_send_attempts_per_24h: int = Field(strict=True, gt=0)
|
||||
min_seconds_between_attempts: int = Field(strict=True, ge=0)
|
||||
max_verify_attempts: int = Field(strict=True, gt=0)
|
||||
code_length: int = Field(strict=True, ge=4, le=10)
|
||||
ttl_seconds: int = Field(strict=True, ge=60, le=900, multiple_of=60)
|
||||
sms_order_timeout_ms: int = Field(strict=True, gt=0)
|
||||
version: str = Field(min_length=1, max_length=64)
|
||||
cache_ttl_seconds: int = Field(strict=True, gt=0)
|
||||
|
||||
|
||||
class Device(StrictModel):
|
||||
platform: Literal["ios", "android", "web"]
|
||||
app_version: str = Field(min_length=1, max_length=64)
|
||||
device_id: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class ConsentChoice(StrictModel):
|
||||
accepted: bool
|
||||
version: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class ConsentSet(StrictModel):
|
||||
personal_data: ConsentChoice
|
||||
user_agreement: ConsentChoice
|
||||
marketing: ConsentChoice
|
||||
|
||||
|
||||
class BootstrapRequest(StrictModel):
|
||||
consents: ConsentSet
|
||||
device: Device
|
||||
|
||||
|
||||
class ConsentsRequest(StrictModel):
|
||||
consents: ConsentSet
|
||||
|
||||
|
||||
class SessionStartRequest(StrictModel):
|
||||
start_reason: Literal["first_launch", "cold_start", "idle_timeout"]
|
||||
device: Device
|
||||
|
||||
|
||||
class TextMessageRequest(StrictModel):
|
||||
content_kind: Literal["text"]
|
||||
text: str = Field(min_length=1, max_length=CHAT_MESSAGE_TRANSPORT_MAX_LENGTH)
|
||||
|
||||
|
||||
class FileMessageRequest(StrictModel):
|
||||
content_kind: Literal["file"]
|
||||
attachment_id: uuid.UUID
|
||||
checksum: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
MessageRequest = Annotated[
|
||||
TextMessageRequest | FileMessageRequest, Field(discriminator="content_kind")
|
||||
]
|
||||
|
||||
|
||||
class AttachmentInitRequest(StrictModel):
|
||||
file_name: str = Field(min_length=1, max_length=255)
|
||||
mime_type: str = Field(min_length=1, max_length=128)
|
||||
size_bytes: int = Field(gt=0)
|
||||
|
||||
|
||||
class AttachmentCompleteRequest(StrictModel):
|
||||
checksum: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class OpenLinesFile(StrictModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
mime_type: str = Field(min_length=1, max_length=128)
|
||||
size_bytes: int = Field(gt=0)
|
||||
download_url: HttpUrl
|
||||
|
||||
|
||||
class OpenLinesMessage(StrictModel):
|
||||
text: str = Field(default="", max_length=4000)
|
||||
files: list[OpenLinesFile] = Field(default_factory=list, max_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def non_empty(self) -> "OpenLinesMessage":
|
||||
if not self.text.strip() and not self.files:
|
||||
raise ValueError("message must contain text or file")
|
||||
return self
|
||||
|
||||
|
||||
class OpenLinesInbox(StrictModel):
|
||||
event_id: str = Field(min_length=1, max_length=255)
|
||||
event_type: Literal["message.new", "dialog.closed"]
|
||||
external_chat_id: uuid.UUID
|
||||
bitrix_message_id: str | None = Field(default=None, max_length=255)
|
||||
occurred_at: datetime
|
||||
message: OpenLinesMessage | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def event_shape(self) -> "OpenLinesInbox":
|
||||
if self.event_type == "message.new" and (
|
||||
not self.bitrix_message_id or self.message is None
|
||||
):
|
||||
raise ValueError("message.new requires bitrix_message_id and message")
|
||||
return self
|
||||
|
||||
|
||||
class DialogStatus(StrEnum):
|
||||
OPEN = "open"
|
||||
WAITING_COMPANY = "waiting_for_company"
|
||||
WAITING_CLIENT = "waiting_for_client"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
def canonical_fingerprint(
|
||||
method: str, route: str, path_params: dict[str, str], body: object, user_id: uuid.UUID
|
||||
) -> str:
|
||||
value = {
|
||||
"method": method.upper(),
|
||||
"route": route,
|
||||
"path": dict(sorted(path_params.items())),
|
||||
"body": body,
|
||||
"user_id": str(user_id),
|
||||
}
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def encode_cursor(data: dict[str, str], secret: bytes) -> str:
|
||||
payload = json.dumps({"v": 1, **data}, sort_keys=True, separators=(",", ":")).encode()
|
||||
signature = hmac.digest(secret, payload, "sha256")
|
||||
return urlsafe_b64encode(payload + signature).decode().rstrip("=")
|
||||
|
||||
|
||||
def decode_cursor(value: str, secret: bytes) -> dict[str, str]:
|
||||
try:
|
||||
raw = urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
payload, signature = raw[:-32], raw[-32:]
|
||||
if not hmac.compare_digest(signature, hmac.digest(secret, payload, "sha256")):
|
||||
raise ValueError("invalid cursor")
|
||||
decoded = json.loads(payload)
|
||||
if decoded.pop("v") != 1:
|
||||
raise ValueError("unsupported cursor")
|
||||
return decoded
|
||||
except (ValueError, KeyError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("invalid cursor") from exc
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import AnyHttpUrl, Field, SecretStr, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env", env_file_encoding="utf-8", case_sensitive=True, extra="ignore"
|
||||
)
|
||||
|
||||
app_env: str = Field(alias="APP_ENV")
|
||||
api_port: int = Field(default=8000, alias="API_PORT")
|
||||
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||
database_url: str = Field(alias="DATABASE_URL")
|
||||
redis_url: str = Field(alias="REDIS_URL")
|
||||
redis_realtime_url: str = Field(alias="REDIS_REALTIME_URL")
|
||||
|
||||
keycloak_public_url: AnyHttpUrl = Field(alias="KEYCLOAK_PUBLIC_URL")
|
||||
keycloak_internal_url: AnyHttpUrl = Field(alias="KEYCLOAK_INTERNAL_URL")
|
||||
keycloak_realm: str = Field(alias="KEYCLOAK_REALM")
|
||||
keycloak_audience: str = Field(alias="KEYCLOAK_AUDIENCE")
|
||||
jwks_cache_ttl_seconds: int = 300
|
||||
jwks_stale_grace_seconds: int = 900
|
||||
|
||||
message_safety_url: AnyHttpUrl = Field(alias="MESSAGE_SAFETY_URL")
|
||||
message_safety_service_token: SecretStr = Field(alias="MESSAGE_SAFETY_SERVICE_TOKEN")
|
||||
message_safety_ca_file: str | None = Field(default=None, alias="MESSAGE_SAFETY_CA_FILE")
|
||||
message_safety_api_prefix: Literal["/internal/safety/v2"] = Field(
|
||||
default="/internal/safety/v2", alias="MESSAGE_SAFETY_API_PREFIX"
|
||||
)
|
||||
message_safety_post_timeout_sec: float = Field(
|
||||
default=5, alias="MESSAGE_SAFETY_POST_TIMEOUT_SEC"
|
||||
)
|
||||
message_safety_task_poll_interval_sec: float = Field(
|
||||
default=2, alias="MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC"
|
||||
)
|
||||
message_safety_task_poll_max_sec: float = Field(
|
||||
default=300, alias="MESSAGE_SAFETY_TASK_POLL_MAX_SEC"
|
||||
)
|
||||
message_safety_circuit_failure_threshold: int = Field(
|
||||
default=5, alias="MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD"
|
||||
)
|
||||
message_safety_circuit_open_sec: int = Field(
|
||||
default=30, alias="MESSAGE_SAFETY_CIRCUIT_OPEN_SEC"
|
||||
)
|
||||
|
||||
bitrix_local_app_base_url: AnyHttpUrl = Field(alias="BITRIX_LOCAL_APP_BASE_URL")
|
||||
bitrix_local_app_internal_token: SecretStr = Field(alias="BITRIX_LOCAL_APP_INTERNAL_TOKEN")
|
||||
bitrix_api_inbox_token: SecretStr = Field(alias="BITRIX_API_INBOX_TOKEN")
|
||||
bitrix_local_app_http_timeout_sec: float = Field(
|
||||
default=20, alias="BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC"
|
||||
)
|
||||
bitrix_local_app_circuit_failure_threshold: int = Field(
|
||||
default=5, alias="BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD"
|
||||
)
|
||||
bitrix_local_app_circuit_open_sec: int = Field(
|
||||
default=30, alias="BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC"
|
||||
)
|
||||
keycloak_settings_bridge_token: SecretStr = Field(alias="KEYCLOAK_SETTINGS_BRIDGE_TOKEN")
|
||||
|
||||
selectel_s3_endpoint_url: AnyHttpUrl = Field(alias="SELECTEL_S3_ENDPOINT_URL")
|
||||
selectel_s3_bucket_documents: str = Field(alias="SELECTEL_S3_BUCKET_DOCUMENTS")
|
||||
selectel_s3_bucket_attachments: str = Field(alias="SELECTEL_S3_BUCKET_ATTACHMENTS")
|
||||
selectel_s3_bucket_quarantine: str = Field(alias="SELECTEL_S3_BUCKET_QUARANTINE")
|
||||
selectel_s3_access_key: SecretStr = Field(alias="SELECTEL_S3_ACCESS_KEY")
|
||||
selectel_s3_secret_key: SecretStr = Field(alias="SELECTEL_S3_SECRET_KEY")
|
||||
otel_exporter_otlp_endpoint: str | None = Field(
|
||||
default=None, alias="OTEL_EXPORTER_OTLP_ENDPOINT"
|
||||
)
|
||||
cursor_hmac_secret: SecretStr = Field(alias="CURSOR_HMAC_SECRET")
|
||||
trusted_proxy_cidrs: str = Field(default="127.0.0.1/32", alias="TRUSTED_PROXY_CIDRS")
|
||||
worker_poll_interval_sec: float = Field(default=2, alias="WORKER_POLL_INTERVAL_SEC")
|
||||
notifications_token_producer_test: SecretStr | None = Field(
|
||||
default=None, alias="NOTIFICATIONS_TOKEN_PRODUCER_TEST"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_safety_tls_in_deployed_environments(self) -> "Settings":
|
||||
if self.app_env not in {"local", "test"}:
|
||||
if str(self.message_safety_url).split(":", 1)[0] != "https":
|
||||
raise ValueError("MESSAGE_SAFETY_URL must use HTTPS")
|
||||
if not self.message_safety_ca_file:
|
||||
raise ValueError("MESSAGE_SAFETY_CA_FILE is required")
|
||||
return self
|
||||
|
||||
@property
|
||||
def issuer(self) -> str:
|
||||
return f"{str(self.keycloak_public_url).rstrip('/')}/realms/{self.keycloak_realm}"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from opentelemetry import metrics, trace
|
||||
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.instrumentation.botocore import BotocoreInstrumentor
|
||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||
from opentelemetry.instrumentation.redis import RedisInstrumentor
|
||||
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
|
||||
from opentelemetry.propagate import set_global_textmap
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
|
||||
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TelemetryRuntime:
|
||||
tracer_provider: TracerProvider
|
||||
meter_provider: MeterProvider
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.meter_provider.shutdown()
|
||||
self.tracer_provider.shutdown()
|
||||
|
||||
|
||||
_runtime: TelemetryRuntime | None = None
|
||||
|
||||
|
||||
def _resource(service_name: str) -> Resource:
|
||||
return Resource.create(
|
||||
{
|
||||
"service.name": service_name,
|
||||
"service.namespace": "han-chat",
|
||||
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
|
||||
"deployment.environment": os.getenv("APP_ENV", "production-like"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def init_telemetry(service_name: str | None = None) -> TelemetryRuntime | None:
|
||||
global _runtime
|
||||
if _runtime is not None:
|
||||
return _runtime
|
||||
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
|
||||
if not endpoint:
|
||||
return None
|
||||
|
||||
resource = _resource(service_name or os.getenv("OTEL_SERVICE_NAME", "api-backend"))
|
||||
insecure = endpoint.startswith("http://")
|
||||
set_global_textmap(TraceContextTextMapPropagator())
|
||||
|
||||
tracer_provider = TracerProvider(resource=resource, sampler=ALWAYS_ON)
|
||||
tracer_provider.add_span_processor(
|
||||
BatchSpanProcessor(
|
||||
OTLPSpanExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||
max_queue_size=2048,
|
||||
schedule_delay_millis=5000,
|
||||
max_export_batch_size=512,
|
||||
export_timeout_millis=3000,
|
||||
)
|
||||
)
|
||||
trace.set_tracer_provider(tracer_provider)
|
||||
|
||||
metric_reader = PeriodicExportingMetricReader(
|
||||
OTLPMetricExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||
export_interval_millis=30000,
|
||||
export_timeout_millis=3000,
|
||||
)
|
||||
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
||||
metrics.set_meter_provider(meter_provider)
|
||||
|
||||
HTTPXClientInstrumentor().instrument()
|
||||
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
|
||||
RedisInstrumentor().instrument()
|
||||
BotocoreInstrumentor().instrument()
|
||||
_runtime = TelemetryRuntime(tracer_provider, meter_provider)
|
||||
return _runtime
|
||||
|
||||
|
||||
def instrument_fastapi(app: FastAPI) -> None:
|
||||
FastAPIInstrumentor.instrument_app(
|
||||
app,
|
||||
excluded_urls="/health/live,/health/ready,/nginx-health/live",
|
||||
)
|
||||
|
||||
|
||||
def add_trace_context(
|
||||
_logger: Any,
|
||||
_method_name: str,
|
||||
event_dict: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
context = trace.get_current_span().get_span_context()
|
||||
if context.is_valid:
|
||||
event_dict["trace_id"] = format(context.trace_id, "032x")
|
||||
event_dict["span_id"] = format(context.span_id, "016x")
|
||||
return event_dict
|
||||
|
||||
|
||||
def current_trace_id() -> str | None:
|
||||
context = trace.get_current_span().get_span_context()
|
||||
return format(context.trace_id, "032x") if context.is_valid else None
|
||||
@@ -0,0 +1,383 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import redis.asyncio as redis
|
||||
import structlog
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.db import (
|
||||
Database,
|
||||
DeliveryOutbox,
|
||||
Dialog,
|
||||
Message,
|
||||
MessageAttachment,
|
||||
SafetyTask,
|
||||
UserIdentity,
|
||||
)
|
||||
from app.integrations import (
|
||||
DependencyFailure,
|
||||
OpenLinesClient,
|
||||
S3Client,
|
||||
SafetyClient,
|
||||
fresh_openlines_payload,
|
||||
)
|
||||
from app.notification_models import ClientUploadDraft
|
||||
from app.notification_service import expire_notifications
|
||||
from app.realtime import RealtimeFanout
|
||||
from app.services import load_settings, publish_dialog_status, publish_message_status
|
||||
from app.settings import Settings, get_settings
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
|
||||
async def delivery_once(
|
||||
db: Database,
|
||||
client: OpenLinesClient,
|
||||
s3: S3Client,
|
||||
fanout: RealtimeFanout,
|
||||
settings: Settings,
|
||||
worker_id: str,
|
||||
batch_size: int = 20,
|
||||
) -> int:
|
||||
async with db.sessions() as session:
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(DeliveryOutbox)
|
||||
.where(
|
||||
DeliveryOutbox.status.in_(["pending", "retry"]),
|
||||
DeliveryOutbox.next_attempt_at <= datetime.now(UTC),
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(batch_size)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
ids = [row.id for row in rows]
|
||||
for row in rows:
|
||||
row.status = "processing"
|
||||
row.locked_at = datetime.now(UTC)
|
||||
row.locked_by = worker_id
|
||||
await session.commit()
|
||||
for row_id in ids:
|
||||
async with db.sessions() as session:
|
||||
row = await session.get(DeliveryOutbox, row_id, with_for_update=True)
|
||||
if row is None:
|
||||
continue
|
||||
message = None
|
||||
dialog = None
|
||||
try:
|
||||
payload = await fresh_openlines_payload(row.payload_json, s3)
|
||||
await client.send(row.message_id, payload, f"worker-{worker_id}")
|
||||
row.status = "delivered"
|
||||
message = await session.get(Message, row.message_id)
|
||||
if message:
|
||||
message.delivery_status = "delivered"
|
||||
dialog = await session.get(Dialog, message.dialog_id)
|
||||
if dialog:
|
||||
dialog.status = "waiting_for_company"
|
||||
dialog.last_message_at = datetime.now(UTC)
|
||||
except DependencyFailure:
|
||||
row.attempt_count += 1
|
||||
row.status = "dead_letter" if row.attempt_count >= 12 else "retry"
|
||||
row.next_attempt_at = datetime.now(UTC) + timedelta(
|
||||
seconds=min(3600, 2**row.attempt_count)
|
||||
)
|
||||
row.last_error_code = "dependency_unavailable"
|
||||
row.locked_at = None
|
||||
row.locked_by = None
|
||||
await session.commit()
|
||||
if message:
|
||||
await publish_message_status(fanout, message, settings)
|
||||
if dialog:
|
||||
await publish_dialog_status(fanout, dialog)
|
||||
return len(ids)
|
||||
|
||||
|
||||
async def safety_once(
|
||||
db: Database,
|
||||
safety: SafetyClient,
|
||||
s3: S3Client,
|
||||
fanout: RealtimeFanout,
|
||||
settings: Settings,
|
||||
worker_id: str,
|
||||
batch_size: int = 20,
|
||||
) -> int:
|
||||
async with db.sessions() as session:
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(SafetyTask)
|
||||
.where(
|
||||
SafetyTask.status.in_(["polling", "failed"]),
|
||||
SafetyTask.next_poll_at <= datetime.now(UTC),
|
||||
SafetyTask.expires_at > datetime.now(UTC),
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(batch_size)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
ids = [row.id for row in rows]
|
||||
for row in rows:
|
||||
row.locked_at, row.locked_by = datetime.now(UTC), worker_id
|
||||
await session.commit()
|
||||
for task_id in ids:
|
||||
async with db.sessions() as session:
|
||||
task = await session.get(SafetyTask, task_id, with_for_update=True)
|
||||
if task is None:
|
||||
continue
|
||||
message = None
|
||||
try:
|
||||
verdict = await safety.poll(task.poll_location, f"worker-{worker_id}")
|
||||
message = await session.get(Message, task.message_id)
|
||||
attachment = (
|
||||
await session.get(MessageAttachment, task.attachment_id)
|
||||
if task.attachment_id
|
||||
else None
|
||||
)
|
||||
if verdict["_status"] == 200 and message:
|
||||
message.safety_processing_mode = verdict["processing_mode"]
|
||||
message.safety_config_version = verdict["config_version"]
|
||||
message.safety_rules_version = verdict["rules_version"]
|
||||
if attachment and attachment.quarantine_object_key:
|
||||
destination = f"attachments/dialogs/{message.dialog_id}/{attachment.id}"
|
||||
await s3.promote(
|
||||
attachment.quarantine_object_key,
|
||||
destination,
|
||||
version_id=attachment.quarantine_version_id or "",
|
||||
etag=attachment.quarantine_etag or "",
|
||||
)
|
||||
attachment.storage_bucket = s3.settings.selectel_s3_bucket_attachments
|
||||
attachment.object_key = destination
|
||||
attachment.quarantine_object_key = None
|
||||
attachment.scan_status = (
|
||||
"bypassed"
|
||||
if verdict["processing_mode"] == "mock"
|
||||
else "clean"
|
||||
)
|
||||
message.safety_status = "allowed"
|
||||
task.status = "completed"
|
||||
dialog = await session.get(Dialog, message.dialog_id)
|
||||
user = (
|
||||
await session.get(UserIdentity, dialog.user_id)
|
||||
if dialog
|
||||
else None
|
||||
)
|
||||
if dialog and user:
|
||||
session.add(
|
||||
DeliveryOutbox(
|
||||
message_id=message.id,
|
||||
external_chat_id=dialog.id,
|
||||
payload_json={
|
||||
"message_id": str(message.id),
|
||||
"external_chat_id": str(dialog.id),
|
||||
"occurred_at": message.occurred_at.isoformat(),
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"display_name": user.phone_number,
|
||||
},
|
||||
"message": {
|
||||
"content_kind": message.content_kind,
|
||||
"text": message.text,
|
||||
"files": (
|
||||
[{
|
||||
"attachment_id": str(attachment.id),
|
||||
"name": attachment.safe_file_name,
|
||||
"mime_type": attachment.mime_type,
|
||||
"size_bytes": attachment.size_bytes,
|
||||
"_storage_bucket": attachment.storage_bucket,
|
||||
"_object_key": attachment.object_key,
|
||||
}]
|
||||
if attachment
|
||||
else []
|
||||
),
|
||||
},
|
||||
},
|
||||
next_attempt_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
elif verdict["_status"] == 403 and message:
|
||||
message.safety_processing_mode = verdict["processing_mode"]
|
||||
message.safety_config_version = verdict["config_version"]
|
||||
message.safety_rules_version = verdict["rules_version"]
|
||||
message.text = ""
|
||||
message.safety_status = "blocked"
|
||||
message.delivery_status = "rejected"
|
||||
if attachment and attachment.quarantine_object_key:
|
||||
await s3.delete_quarantine(attachment.quarantine_object_key)
|
||||
attachment.scan_status = "infected"
|
||||
task.status = "completed"
|
||||
else:
|
||||
if verdict["_status"] == 202:
|
||||
task.poll_location = verdict["_location"]
|
||||
task.next_poll_at = datetime.now(UTC) + timedelta(seconds=2)
|
||||
except DependencyFailure as exc:
|
||||
task.attempt_count += 1
|
||||
terminal = exc.terminal or (
|
||||
exc.code == "task_not_found" and task.attempt_count >= 2
|
||||
)
|
||||
task.status = "terminal_failed" if terminal else "failed"
|
||||
task.last_error_code = exc.code
|
||||
if terminal and message:
|
||||
message.delivery_status = "failed"
|
||||
task.next_poll_at = datetime.now(UTC) + timedelta(
|
||||
seconds=min(300, 2**task.attempt_count)
|
||||
)
|
||||
task.locked_at = None
|
||||
task.locked_by = None
|
||||
await session.commit()
|
||||
if message:
|
||||
await publish_message_status(fanout, message, settings)
|
||||
return len(ids)
|
||||
|
||||
|
||||
async def cleanup_once(db: Database, s3: S3Client, batch_size: int = 100) -> int:
|
||||
async with db.sessions() as session:
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(MessageAttachment)
|
||||
.where(
|
||||
MessageAttachment.record_status == "A",
|
||||
MessageAttachment.quarantine_object_key.is_not(None),
|
||||
MessageAttachment.upload_expires_at < datetime.now(UTC),
|
||||
MessageAttachment.message_id.is_(None),
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(batch_size)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
if row.quarantine_object_key:
|
||||
await s3.delete_quarantine(row.quarantine_object_key)
|
||||
row.record_status = "D"
|
||||
row.status_changed_at = datetime.now(UTC)
|
||||
row.status_change_reason = "expired_quarantine_cleanup"
|
||||
await session.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def loop(kind: str) -> None:
|
||||
settings = get_settings()
|
||||
db = Database(settings.database_url)
|
||||
http = httpx.AsyncClient()
|
||||
safety = SafetyClient(settings, http)
|
||||
openlines = OpenLinesClient(settings, http)
|
||||
s3 = S3Client(settings)
|
||||
redis_rt = redis.from_url(settings.redis_realtime_url, decode_responses=True)
|
||||
fanout = RealtimeFanout(redis_rt)
|
||||
worker_id = f"{kind}-{uuid.uuid4()}"
|
||||
try:
|
||||
while True:
|
||||
count = 0
|
||||
if kind == "delivery":
|
||||
count = await delivery_once(db, openlines, s3, fanout, settings, worker_id)
|
||||
elif kind == "safety":
|
||||
count = await safety_once(db, safety, s3, fanout, settings, worker_id)
|
||||
else:
|
||||
count = await cleanup_once(db, s3)
|
||||
if not count:
|
||||
await asyncio.sleep(settings.worker_poll_interval_sec)
|
||||
finally:
|
||||
await http.aclose()
|
||||
await redis_rt.aclose()
|
||||
await db.close()
|
||||
|
||||
|
||||
async def notification_expire_loop() -> None:
|
||||
settings = get_settings()
|
||||
db = Database(settings.database_url)
|
||||
try:
|
||||
while True:
|
||||
async with db.sessions() as session:
|
||||
snapshot = await load_settings(session)
|
||||
run_at = snapshot.values["notification.expire_job.run_at"]
|
||||
hour, minute = (int(value) for value in run_at.split(":", 1))
|
||||
now = datetime.now(UTC)
|
||||
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if target <= now:
|
||||
target += timedelta(days=1)
|
||||
await asyncio.sleep((target - now).total_seconds())
|
||||
async with db.sessions() as session:
|
||||
personal, guest = await expire_notifications(session)
|
||||
log.info(
|
||||
"notification.expired_batch",
|
||||
personal_count=personal,
|
||||
guest_count=guest,
|
||||
)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def notification_draft_cleanup_once(db: Database, s3: S3Client) -> int:
|
||||
async with db.sessions() as session:
|
||||
snapshot = await load_settings(session)
|
||||
cutoff = datetime.now(UTC) - timedelta(
|
||||
days=snapshot.integer("notification.upload_draft.ttl_days")
|
||||
)
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(ClientUploadDraft)
|
||||
.where(ClientUploadDraft.created_at < cutoff)
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(100)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
if row.state != "submitted":
|
||||
if row.quarantine_object_key:
|
||||
await s3.delete_quarantine(row.quarantine_object_key)
|
||||
elif row.object_key:
|
||||
await s3.delete(row.storage_bucket, row.object_key)
|
||||
await session.execute(
|
||||
delete(ClientUploadDraft).where(ClientUploadDraft.id == row.id)
|
||||
)
|
||||
await session.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def notification_draft_cleanup_loop() -> None:
|
||||
settings = get_settings()
|
||||
db = Database(settings.database_url)
|
||||
s3 = S3Client(settings)
|
||||
try:
|
||||
while True:
|
||||
count = await notification_draft_cleanup_once(db, s3)
|
||||
if count < 100:
|
||||
await asyncio.sleep(86400)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
def delivery_main() -> None:
|
||||
asyncio.run(loop("delivery"))
|
||||
|
||||
|
||||
def safety_main() -> None:
|
||||
asyncio.run(loop("safety"))
|
||||
|
||||
|
||||
def cleanup_main() -> None:
|
||||
asyncio.run(loop("cleanup"))
|
||||
|
||||
|
||||
def notification_expire_main() -> None:
|
||||
asyncio.run(notification_expire_loop())
|
||||
|
||||
|
||||
def notification_draft_cleanup_main() -> None:
|
||||
asyncio.run(notification_draft_cleanup_loop())
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
for name in ${HAN_SECRET_VARS:-}; do
|
||||
case "$name" in
|
||||
""|[0-9]*|*[!A-Z0-9_]*)
|
||||
echo "container secrets: invalid variable name" >&2
|
||||
exit 64
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
eval "file=\${${name}_FILE:-}"
|
||||
if [ -z "$file" ] || [ ! -r "$file" ]; then
|
||||
echo "container secrets: missing file for $name" >&2
|
||||
exit 66
|
||||
fi
|
||||
value=$(cat "$file")
|
||||
export "$name=$value"
|
||||
unset "${name}_FILE"
|
||||
done
|
||||
|
||||
unset HAN_SECRET_VARS
|
||||
exec "$@"
|
||||
@@ -0,0 +1,103 @@
|
||||
services:
|
||||
api-backend:
|
||||
build: .
|
||||
image: han-chat/api-backend:local
|
||||
expose:
|
||||
- "8000"
|
||||
environment:
|
||||
HAN_SECRET_VARS: >-
|
||||
DATABASE_URL REDIS_URL REDIS_REALTIME_URL MESSAGE_SAFETY_SERVICE_TOKEN
|
||||
BITRIX_LOCAL_APP_INTERNAL_TOKEN BITRIX_API_INBOX_TOKEN
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN SELECTEL_S3_ACCESS_KEY SELECTEL_S3_SECRET_KEY
|
||||
CURSOR_HMAC_SECRET
|
||||
DATABASE_URL_FILE: /run/secrets/api_database_url
|
||||
REDIS_URL_FILE: /run/secrets/api_redis_url
|
||||
REDIS_REALTIME_URL_FILE: /run/secrets/api_redis_realtime_url
|
||||
MESSAGE_SAFETY_SERVICE_TOKEN_FILE: /run/secrets/message_safety_service_token
|
||||
BITRIX_LOCAL_APP_INTERNAL_TOKEN_FILE: /run/secrets/bitrix_local_app_internal_token
|
||||
BITRIX_API_INBOX_TOKEN_FILE: /run/secrets/bitrix_api_inbox_token
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN_FILE: /run/secrets/keycloak_settings_bridge_token
|
||||
SELECTEL_S3_ACCESS_KEY_FILE: /run/secrets/selectel_s3_access_key
|
||||
SELECTEL_S3_SECRET_KEY_FILE: /run/secrets/selectel_s3_secret_key
|
||||
CURSOR_HMAC_SECRET_FILE: /run/secrets/cursor_hmac_secret
|
||||
APP_ENV: ${APP_ENV}
|
||||
API_PORT: ${API_PORT:-8000}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL}
|
||||
KEYCLOAK_INTERNAL_URL: ${KEYCLOAK_INTERNAL_URL}
|
||||
KEYCLOAK_REALM: ${KEYCLOAK_REALM}
|
||||
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE}
|
||||
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL}
|
||||
MESSAGE_SAFETY_API_PREFIX: /internal/safety/v2
|
||||
MESSAGE_SAFETY_CA_FILE: /run/config/message-safety-internal-ca.pem
|
||||
MESSAGE_SAFETY_POST_TIMEOUT_SEC: ${MESSAGE_SAFETY_POST_TIMEOUT_SEC:-5}
|
||||
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC: ${MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC:-2}
|
||||
MESSAGE_SAFETY_TASK_POLL_MAX_SEC: ${MESSAGE_SAFETY_TASK_POLL_MAX_SEC:-300}
|
||||
MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD: ${MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD:-5}
|
||||
MESSAGE_SAFETY_CIRCUIT_OPEN_SEC: ${MESSAGE_SAFETY_CIRCUIT_OPEN_SEC:-30}
|
||||
BITRIX_LOCAL_APP_BASE_URL: ${BITRIX_LOCAL_APP_BASE_URL}
|
||||
BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC: ${BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC:-20}
|
||||
BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD: ${BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD:-5}
|
||||
BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC: ${BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC:-30}
|
||||
SELECTEL_S3_ENDPOINT_URL: ${SELECTEL_S3_ENDPOINT_URL}
|
||||
SELECTEL_S3_BUCKET_DOCUMENTS: ${SELECTEL_S3_BUCKET_DOCUMENTS}
|
||||
SELECTEL_S3_BUCKET_ATTACHMENTS: ${SELECTEL_S3_BUCKET_ATTACHMENTS}
|
||||
SELECTEL_S3_BUCKET_QUARANTINE: ${SELECTEL_S3_BUCKET_QUARANTINE}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT}
|
||||
secrets:
|
||||
- api_database_url
|
||||
- api_redis_url
|
||||
- api_redis_realtime_url
|
||||
- message_safety_service_token
|
||||
- bitrix_local_app_internal_token
|
||||
- bitrix_api_inbox_token
|
||||
- keycloak_settings_bridge_token
|
||||
- selectel_s3_access_key
|
||||
- selectel_s3_secret_key
|
||||
- cursor_hmac_secret
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${MESSAGE_SAFETY_CA_HOST_PATH}
|
||||
target: /run/config/message-safety-internal-ca.pem
|
||||
read_only: true
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/live', timeout=2)"
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
networks:
|
||||
- backend
|
||||
- observability
|
||||
|
||||
networks:
|
||||
backend:
|
||||
observability:
|
||||
|
||||
secrets:
|
||||
api_database_url:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/DATABASE_URL
|
||||
api_redis_url:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/REDIS_URL
|
||||
api_redis_realtime_url:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/REDIS_REALTIME_URL
|
||||
message_safety_service_token:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/MESSAGE_SAFETY_SERVICE_TOKEN
|
||||
bitrix_local_app_internal_token:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/BITRIX_LOCAL_APP_INTERNAL_TOKEN
|
||||
bitrix_api_inbox_token:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/BITRIX_API_INBOX_TOKEN
|
||||
keycloak_settings_bridge_token:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/KEYCLOAK_SETTINGS_BRIDGE_TOKEN
|
||||
selectel_s3_access_key:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/SELECTEL_S3_ACCESS_KEY
|
||||
selectel_s3_secret_key:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/SELECTEL_S3_SECRET_KEY
|
||||
cursor_hmac_secret:
|
||||
file: ${HAN_SECRETS_DIR:-/run/han-chat/secrets}/CURSOR_HMAC_SECRET
|
||||
@@ -0,0 +1,588 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HAN Chat API
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
/health/live:
|
||||
get:
|
||||
operationId: healthLive
|
||||
responses:
|
||||
"200": {description: Process is live}
|
||||
/health/ready:
|
||||
get:
|
||||
operationId: healthReady
|
||||
responses:
|
||||
"200": {description: Ready or partially degraded}
|
||||
"503": {$ref: "#/components/responses/DependencyUnavailable"}
|
||||
/api/v1/public/app-config:
|
||||
get:
|
||||
operationId: getPublicAppConfig
|
||||
responses:
|
||||
"200":
|
||||
description: Public application configuration
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [messages]
|
||||
properties:
|
||||
messages:
|
||||
type: object
|
||||
required: [max_text_length]
|
||||
properties:
|
||||
max_text_length: {type: integer, minimum: 1, maximum: 10000}
|
||||
/api/v1/public/content:
|
||||
get:
|
||||
operationId: getPublicContent
|
||||
parameters:
|
||||
- {name: locale, in: query, schema: {type: string, default: ru}}
|
||||
responses:
|
||||
"200": {description: Active UI content}
|
||||
/api/v1/auth/bootstrap:
|
||||
post:
|
||||
operationId: bootstrap
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/BootstrapRequest"}
|
||||
responses:
|
||||
"200": {description: Local user resolved}
|
||||
"401": {$ref: "#/components/responses/Unauthorized"}
|
||||
/api/v1/consents:
|
||||
post:
|
||||
operationId: recordConsents
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/ConsentsRequest"}
|
||||
responses:
|
||||
"201": {description: Immutable consent records saved}
|
||||
/api/v1/analytics/session-start:
|
||||
post:
|
||||
operationId: startUxSession
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/SessionStartRequest"}
|
||||
responses:
|
||||
"201": {description: UX session created}
|
||||
/api/v1/me:
|
||||
get:
|
||||
operationId: getCurrentProfile
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Block profile}
|
||||
/api/v1/me/documents:
|
||||
get:
|
||||
operationId: listDocuments
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Cursor-paginated documents}
|
||||
/api/v1/documents/{document_id}:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DocumentId"}
|
||||
get:
|
||||
operationId: getDocument
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Document metadata}
|
||||
"404": {$ref: "#/components/responses/NotFound"}
|
||||
/api/v1/documents/{document_id}/download-url:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DocumentId"}
|
||||
get:
|
||||
operationId: getDocumentDownloadUrl
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Short-lived presigned GET}
|
||||
/api/v1/dialogs:
|
||||
post:
|
||||
operationId: createDialog
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/IdempotencyKey"}
|
||||
responses:
|
||||
"200": {description: Existing active dialog}
|
||||
"201": {description: New active dialog}
|
||||
get:
|
||||
operationId: listDialogs
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Cursor-paginated dialogs}
|
||||
/api/v1/dialogs/{dialog_id}:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DialogId"}
|
||||
get:
|
||||
operationId: getDialog
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Dialog summary}
|
||||
"404": {$ref: "#/components/responses/NotFound"}
|
||||
/api/v1/dialogs/{dialog_id}/messages:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DialogId"}
|
||||
get:
|
||||
operationId: listMessages
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- {name: after, in: query, schema: {type: string}}
|
||||
- {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 50}}
|
||||
responses:
|
||||
"200": {description: Message history or polling delta}
|
||||
post:
|
||||
operationId: sendMessage
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/IdempotencyKey"}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- {$ref: "#/components/schemas/TextMessageRequest"}
|
||||
- {$ref: "#/components/schemas/FileMessageRequest"}
|
||||
discriminator: {propertyName: content_kind}
|
||||
responses:
|
||||
"201": {description: Safety-allowed and delivered message}
|
||||
"422": {description: Message blocked, content redacted}
|
||||
"503": {$ref: "#/components/responses/DependencyUnavailable"}
|
||||
/api/v1/dialogs/{dialog_id}/attachments/init:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DialogId"}
|
||||
post:
|
||||
operationId: initAttachment
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/AttachmentInitRequest"}
|
||||
responses:
|
||||
"201": {description: Upload metadata and presigned PUT}
|
||||
/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DialogId"}
|
||||
- {$ref: "#/components/parameters/AttachmentId"}
|
||||
post:
|
||||
operationId: completeAttachment
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/ChecksumRequest"}
|
||||
responses:
|
||||
"200": {description: Upload metadata verified}
|
||||
/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DialogId"}
|
||||
- {$ref: "#/components/parameters/AttachmentId"}
|
||||
get:
|
||||
operationId: getAttachmentDownloadUrl
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Audited short-lived presigned GET}
|
||||
/api/v1/public/notifications:
|
||||
get:
|
||||
operationId: listGuestNotifications
|
||||
responses:
|
||||
"200":
|
||||
description: Server-sorted active guest campaigns
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/NotificationList"}
|
||||
/api/v1/public/notification-types:
|
||||
get:
|
||||
operationId: getNotificationCatalog
|
||||
parameters:
|
||||
- {name: If-None-Match, in: header, schema: {type: string}}
|
||||
responses:
|
||||
"200": {description: Public catalog with ETag}
|
||||
"304": {description: Catalog is unchanged}
|
||||
/api/v1/notifications:
|
||||
get:
|
||||
operationId: listNotifications
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- {name: place, in: query, required: true, schema: {type: string, enum: [home, center]}}
|
||||
responses:
|
||||
"200":
|
||||
description: Server-sorted personal notifications
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/NotificationList"}
|
||||
/api/v1/notifications/counter:
|
||||
get:
|
||||
operationId: getNotificationCounter
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200":
|
||||
description: Unread count in the center window
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/NotificationCounter"}
|
||||
/api/v1/notifications/{notification_id}:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/NotificationId"}
|
||||
get:
|
||||
operationId: getNotification
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Active notification detail}
|
||||
"404": {$ref: "#/components/responses/NotFound"}
|
||||
/api/v1/notifications/{notification_id}/read:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/NotificationId"}
|
||||
post:
|
||||
operationId: readNotification
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Current notification state}
|
||||
"409": {$ref: "#/components/responses/Conflict"}
|
||||
/api/v1/notifications/{notification_id}/hide:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/NotificationId"}
|
||||
post:
|
||||
operationId: hideNotification
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Current notification state}
|
||||
"409": {$ref: "#/components/responses/Conflict"}
|
||||
/api/v1/notifications/{notification_id}/buttons/{button_code}:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/NotificationId"}
|
||||
- {name: button_code, in: path, required: true, schema: {type: string}}
|
||||
post:
|
||||
operationId: pressNotificationButton
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Current notification state}
|
||||
"409": {$ref: "#/components/responses/Conflict"}
|
||||
"422": {$ref: "#/components/responses/Unprocessable"}
|
||||
/api/v1/notifications/{notification_id}/cta:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/NotificationId"}
|
||||
post:
|
||||
operationId: invokeNotificationCta
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: CTA result and current state}
|
||||
"409": {$ref: "#/components/responses/Conflict"}
|
||||
/api/v1/notifications/{notification_id}/documents/{document_id}/download-url:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/NotificationId"}
|
||||
- {$ref: "#/components/parameters/DocumentId"}
|
||||
get:
|
||||
operationId: getNotificationDocumentDownloadUrl
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"200": {description: Audited short-lived presigned GET}
|
||||
"404": {$ref: "#/components/responses/NotFound"}
|
||||
/api/v1/uploads/init:
|
||||
post:
|
||||
operationId: initClientUpload
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/UploadInitRequest"}
|
||||
responses:
|
||||
"201": {description: Upload draft and presigned PUT}
|
||||
/api/v1/uploads/{draft_id}/complete:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DraftId"}
|
||||
post:
|
||||
operationId: completeClientUpload
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/ChecksumRequest"}
|
||||
responses:
|
||||
"200": {description: Current scan state}
|
||||
/api/v1/uploads:
|
||||
get:
|
||||
operationId: listClientUploads
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- {name: context_type, in: query, required: true, schema: {type: string, enum: [notification]}}
|
||||
- {name: context_id, in: query, required: true, schema: {type: string, format: uuid}}
|
||||
responses:
|
||||
"200": {description: Upload drafts for context}
|
||||
/api/v1/uploads/{draft_id}:
|
||||
parameters:
|
||||
- {$ref: "#/components/parameters/DraftId"}
|
||||
delete:
|
||||
operationId: deleteClientUpload
|
||||
security: [{bearerAuth: []}]
|
||||
responses:
|
||||
"204": {description: Draft discarded}
|
||||
/internal/notifications/v1/notifications:
|
||||
post:
|
||||
operationId: createNotification
|
||||
security: [{serviceBearer: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/NotificationCreateRequest"}
|
||||
responses:
|
||||
"200": {description: Idempotent duplicate}
|
||||
"201": {description: Notification created}
|
||||
"409": {$ref: "#/components/responses/Conflict"}
|
||||
/internal/notifications/v1/notifications/cancel:
|
||||
post:
|
||||
operationId: cancelNotification
|
||||
security: [{serviceBearer: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/NotificationCancelRequest"}
|
||||
responses:
|
||||
"200": {description: Notification closed or already closed}
|
||||
"404": {$ref: "#/components/responses/NotFound"}
|
||||
/internal/openlines/v1/inbox:
|
||||
post:
|
||||
operationId: applyOpenLinesInbox
|
||||
security: [{serviceBearer: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/OpenLinesInbox"}
|
||||
responses:
|
||||
"200": {description: Duplicate acknowledged}
|
||||
"201": {description: Event applied}
|
||||
/internal/settings/v1/otp:
|
||||
get:
|
||||
operationId: getOtpSettings
|
||||
security: [{serviceBearer: []}]
|
||||
responses:
|
||||
"200":
|
||||
description: Product OTP limits and cache metadata
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/OtpSettingsResponse"}
|
||||
"304": {description: Cached settings are still current}
|
||||
"503": {$ref: "#/components/responses/DependencyUnavailable"}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||
serviceBearer: {type: http, scheme: bearer}
|
||||
parameters:
|
||||
DialogId: {name: dialog_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||
AttachmentId: {name: attachment_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||
DocumentId: {name: document_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||
NotificationId: {name: notification_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||
DraftId: {name: draft_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||
responses:
|
||||
Unauthorized:
|
||||
description: JWT is absent, invalid or expired
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||
NotFound:
|
||||
description: Resource absent or owned by another user
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||
DependencyUnavailable:
|
||||
description: Required dependency is unavailable
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||
Conflict:
|
||||
description: Notification state or idempotency conflict
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||
Unprocessable:
|
||||
description: Catalog action is not allowed
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||
schemas:
|
||||
OtpSettingsResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- max_send_attempts_per_24h
|
||||
- min_seconds_between_attempts
|
||||
- max_verify_attempts
|
||||
- code_length
|
||||
- ttl_seconds
|
||||
- sms_order_timeout_ms
|
||||
- version
|
||||
- cache_ttl_seconds
|
||||
properties:
|
||||
max_send_attempts_per_24h: {type: integer, minimum: 1}
|
||||
min_seconds_between_attempts: {type: integer, minimum: 0}
|
||||
max_verify_attempts: {type: integer, minimum: 1}
|
||||
code_length: {type: integer, minimum: 4, maximum: 10}
|
||||
ttl_seconds: {type: integer, minimum: 60, maximum: 900, multipleOf: 60}
|
||||
sms_order_timeout_ms: {type: integer, minimum: 1}
|
||||
version: {type: string, minLength: 1, maxLength: 64}
|
||||
cache_ttl_seconds: {type: integer, minimum: 1}
|
||||
ErrorEnvelope:
|
||||
type: object
|
||||
required: [error]
|
||||
properties:
|
||||
error:
|
||||
type: object
|
||||
required: [code, message, request_id, details]
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
request_id: {type: string}
|
||||
details: {type: object}
|
||||
NotificationCounter:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [unread_count]
|
||||
properties:
|
||||
unread_count: {type: integer, minimum: 0}
|
||||
NotificationList:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items: {$ref: "#/components/schemas/Notification"}
|
||||
Notification:
|
||||
type: object
|
||||
required: [id, notification_type, notification_datetime, header]
|
||||
properties:
|
||||
id: {type: string, format: uuid}
|
||||
notification_type: {type: string}
|
||||
notification_datetime: {type: string, format: date-time}
|
||||
header: {type: string}
|
||||
text: {type: [string, "null"]}
|
||||
instruction_url: {type: [string, "null"], format: uri}
|
||||
instruction_open_mode: {type: [string, "null"], enum: [new_tab, null]}
|
||||
lifecycle_status: {type: string, enum: [active, closed]}
|
||||
visibility: {type: string, enum: [visible, hidden]}
|
||||
is_read: {type: boolean}
|
||||
details: {type: [object, "null"]}
|
||||
NotificationCreateRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [user_id, notification_type, source, external_id, notification_datetime, header]
|
||||
properties:
|
||||
user_id: {type: string, format: uuid}
|
||||
notification_type: {type: string, maxLength: 32}
|
||||
source: {type: string, maxLength: 32}
|
||||
external_id: {type: string, maxLength: 128}
|
||||
notification_datetime: {type: string, format: date-time}
|
||||
header: {type: string, maxLength: 255}
|
||||
text: {type: [string, "null"], maxLength: 1024}
|
||||
priority_override: {type: [integer, "null"]}
|
||||
date_expired: {type: [string, "null"], format: date-time}
|
||||
price: {type: [number, "null"]}
|
||||
old_price: {type: [number, "null"]}
|
||||
payment_url: {type: [string, "null"], format: uri}
|
||||
chat_message_text: {type: [string, "null"], maxLength: 1024}
|
||||
details: {type: [object, "null"]}
|
||||
NotificationCancelRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [source, external_id, close_reason]
|
||||
properties:
|
||||
source: {type: string}
|
||||
external_id: {type: string}
|
||||
close_reason: {type: string, enum: [cancelled, paid]}
|
||||
UploadInitRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [context_type, context_id, file_name, mime_type, size_bytes]
|
||||
properties:
|
||||
context_type: {const: notification}
|
||||
context_id: {type: string, format: uuid}
|
||||
file_name: {type: string, maxLength: 255}
|
||||
mime_type: {type: string, maxLength: 128}
|
||||
size_bytes: {type: integer, minimum: 1}
|
||||
ConsentChoice:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [accepted, version]
|
||||
properties:
|
||||
accepted: {type: boolean}
|
||||
version: {type: string, maxLength: 64}
|
||||
ConsentSet:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [personal_data, user_agreement, marketing]
|
||||
properties:
|
||||
personal_data: {$ref: "#/components/schemas/ConsentChoice"}
|
||||
user_agreement: {$ref: "#/components/schemas/ConsentChoice"}
|
||||
marketing: {$ref: "#/components/schemas/ConsentChoice"}
|
||||
Device:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [platform, app_version]
|
||||
properties:
|
||||
platform: {type: string, enum: [ios, android, web]}
|
||||
app_version: {type: string, maxLength: 64}
|
||||
device_id: {type: [string, "null"], maxLength: 255}
|
||||
BootstrapRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [consents, device]
|
||||
properties:
|
||||
consents: {$ref: "#/components/schemas/ConsentSet"}
|
||||
device: {$ref: "#/components/schemas/Device"}
|
||||
ConsentsRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [consents]
|
||||
properties: {consents: {$ref: "#/components/schemas/ConsentSet"}}
|
||||
SessionStartRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [start_reason, device]
|
||||
properties:
|
||||
start_reason: {type: string, enum: [first_launch, cold_start, idle_timeout]}
|
||||
device: {$ref: "#/components/schemas/Device"}
|
||||
TextMessageRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [content_kind, text]
|
||||
properties:
|
||||
content_kind: {const: text}
|
||||
text: {type: string, minLength: 1, maxLength: 10000}
|
||||
FileMessageRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [content_kind, attachment_id, checksum]
|
||||
properties:
|
||||
content_kind: {const: file}
|
||||
attachment_id: {type: string, format: uuid}
|
||||
checksum: {type: string, pattern: "^sha256:[0-9a-f]{64}$"}
|
||||
AttachmentInitRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [file_name, mime_type, size_bytes]
|
||||
properties:
|
||||
file_name: {type: string, minLength: 1, maxLength: 255}
|
||||
mime_type: {type: string, minLength: 1, maxLength: 128}
|
||||
size_bytes: {type: integer, minimum: 1}
|
||||
ChecksumRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [checksum]
|
||||
properties:
|
||||
checksum: {type: string, pattern: "^sha256:[0-9a-f]{64}$"}
|
||||
OpenLinesInbox:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [event_id, event_type, external_chat_id, occurred_at]
|
||||
properties:
|
||||
event_id: {type: string, minLength: 1, maxLength: 255}
|
||||
event_type: {type: string, enum: [message.new, dialog.closed]}
|
||||
external_chat_id: {type: string, format: uuid}
|
||||
bitrix_message_id: {type: [string, "null"], maxLength: 255}
|
||||
occurred_at: {type: string, format: date-time}
|
||||
message: {type: [object, "null"]}
|
||||
@@ -0,0 +1,77 @@
|
||||
[project]
|
||||
name = "han-api-backend"
|
||||
version = "0.1.0"
|
||||
description = "HAN Chat FastAPI backend"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"alembic>=1.16,<2",
|
||||
"asyncpg>=0.30,<1",
|
||||
"boto3>=1.39,<2",
|
||||
"fastapi>=0.116,<1",
|
||||
"httpx>=0.28,<1",
|
||||
"opentelemetry-api>=1.44,<2",
|
||||
"opentelemetry-exporter-otlp-proto-grpc>=1.44,<2",
|
||||
"opentelemetry-instrumentation-botocore>=0.65b0,<1",
|
||||
"opentelemetry-instrumentation-fastapi>=0.65b0,<1",
|
||||
"opentelemetry-instrumentation-httpx>=0.65b0,<1",
|
||||
"opentelemetry-instrumentation-redis>=0.65b0,<1",
|
||||
"opentelemetry-instrumentation-sqlalchemy>=0.65b0,<1",
|
||||
"opentelemetry-sdk>=1.44,<2",
|
||||
"phonenumbers>=9,<10",
|
||||
"prometheus-client>=0.22,<1",
|
||||
"pydantic-settings>=2.10,<3",
|
||||
"pyyaml>=6,<7",
|
||||
"pyjwt[crypto]>=2.10,<3",
|
||||
"redis>=6,<7",
|
||||
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||
"structlog>=25,<26",
|
||||
"uvicorn[standard]>=0.35,<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"aiosqlite>=0.21,<1",
|
||||
"mypy>=1.16,<2",
|
||||
"pytest>=8.4,<9",
|
||||
"pytest-asyncio>=1.0,<2",
|
||||
"ruff>=0.12,<1",
|
||||
"types-boto3-s3>=1.39,<2",
|
||||
"types-PyYAML>=6,<7",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
han-api = "app.main:run"
|
||||
han-delivery-worker = "app.workers:delivery_main"
|
||||
han-safety-worker = "app.workers:safety_main"
|
||||
han-cleanup-worker = "app.workers:cleanup_main"
|
||||
han-notification-expire-worker = "app.workers:notification_expire_main"
|
||||
han-notification-draft-cleanup-worker = "app.workers:notification_draft_cleanup_main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "ASYNC", "S"]
|
||||
ignore = ["S101"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
check_untyped_defs = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
ignore_missing_imports = true
|
||||
disable_error_code = ["no-untyped-def", "misc", "arg-type", "assignment"]
|
||||
plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"]
|
||||
exclude = ["alembic/"]
|
||||
@@ -0,0 +1,249 @@
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.integrations import (
|
||||
DependencyFailure,
|
||||
OpenLinesClient,
|
||||
S3Client,
|
||||
SafetyClient,
|
||||
fresh_openlines_payload,
|
||||
)
|
||||
from app.realtime import CHANNEL_PREFIX
|
||||
from app.settings import Settings
|
||||
|
||||
|
||||
def settings() -> Settings:
|
||||
common = {
|
||||
"APP_ENV": "test",
|
||||
"DATABASE_URL": "postgresql+asyncpg://u:p@localhost/db",
|
||||
"REDIS_URL": "redis://localhost/0",
|
||||
"REDIS_REALTIME_URL": "redis://localhost/1",
|
||||
"KEYCLOAK_PUBLIC_URL": "https://auth.example",
|
||||
"KEYCLOAK_INTERNAL_URL": "http://keycloak:8080",
|
||||
"KEYCLOAK_REALM": "han",
|
||||
"KEYCLOAK_AUDIENCE": "api",
|
||||
"MESSAGE_SAFETY_URL": "http://safety:8080",
|
||||
"MESSAGE_SAFETY_SERVICE_TOKEN": "safety-token",
|
||||
"BITRIX_LOCAL_APP_BASE_URL": "http://bitrix:8080",
|
||||
"BITRIX_LOCAL_APP_INTERNAL_TOKEN": "bitrix-token",
|
||||
"BITRIX_API_INBOX_TOKEN": "inbox-token",
|
||||
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN": "settings-token",
|
||||
"SELECTEL_S3_ENDPOINT_URL": "https://s3.example",
|
||||
"SELECTEL_S3_BUCKET_DOCUMENTS": "documents",
|
||||
"SELECTEL_S3_BUCKET_ATTACHMENTS": "attachments",
|
||||
"SELECTEL_S3_BUCKET_QUARANTINE": "quarantine",
|
||||
"SELECTEL_S3_ACCESS_KEY": "access",
|
||||
"SELECTEL_S3_SECRET_KEY": "secret",
|
||||
"CURSOR_HMAC_SECRET": "x" * 32,
|
||||
}
|
||||
return Settings.model_validate(common)
|
||||
|
||||
|
||||
def test_realtime_channel_matches_redis_acl_namespace() -> None:
|
||||
assert CHANNEL_PREFIX == "han:rt:dialog:"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_s3_presigned_urls_use_virtual_hosted_addressing() -> None:
|
||||
s3 = S3Client(settings())
|
||||
|
||||
put_url = await s3.presign_put("quarantine/users/u/file.pdf", "application/pdf", 600)
|
||||
get_url = await s3.presign_get("attachments", "dialogs/d/file.pdf")
|
||||
|
||||
assert urlsplit(put_url).netloc == "quarantine.s3.example"
|
||||
assert urlsplit(put_url).path == "/quarantine/users/u/file.pdf"
|
||||
assert urlsplit(get_url).netloc == "attachments.s3.example"
|
||||
assert urlsplit(get_url).path == "/dialogs/d/file.pdf"
|
||||
assert parse_qs(urlsplit(put_url).query)["X-Amz-Algorithm"] == ["AWS4-HMAC-SHA256"]
|
||||
assert parse_qs(urlsplit(get_url).query)["X-Amz-Algorithm"] == ["AWS4-HMAC-SHA256"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_contract_status_and_service_token() -> None:
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["X-Service-Token"] == "safety-token"
|
||||
assert request.url.path == "/internal/safety/v2/messages/check"
|
||||
return httpx.Response(
|
||||
202,
|
||||
headers={
|
||||
"Location": f"/internal/safety/v2/messages/tasks/{task_id}",
|
||||
"Retry-After": "2",
|
||||
},
|
||||
json={
|
||||
"verdict": "pending",
|
||||
"processing_mode": "standard",
|
||||
"config_version": 1,
|
||||
"rules_version": "2026-01-01",
|
||||
"task_id": task_id,
|
||||
"poll_after_ms": 2000,
|
||||
"expires_at": "2026-08-06T12:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
result = await SafetyClient(settings(), http).check(
|
||||
{"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "hello"},
|
||||
"request-1",
|
||||
)
|
||||
assert result["_status"] == 202
|
||||
assert result["_location"] == f"/internal/safety/v2/messages/tasks/{task_id}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_file_body_uses_exact_attachment_schema() -> None:
|
||||
attachment_id = uuid.uuid4()
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = __import__("json").loads(request.content)
|
||||
assert body["attachment"] == {
|
||||
"attachment_id": str(attachment_id),
|
||||
"quarantine_object_key": "quarantine/users/u/file",
|
||||
"quarantine_version_id": "version-1",
|
||||
"quarantine_etag": '"etag-1"',
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": 42,
|
||||
"checksum": "sha256:" + "a" * 64,
|
||||
}
|
||||
assert "file" not in body
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"verdict": "allow",
|
||||
"processing_mode": "standard",
|
||||
"config_version": 1,
|
||||
"rules_version": "2026-01-01",
|
||||
"rule_id": "safety.all_checks_passed",
|
||||
},
|
||||
)
|
||||
|
||||
payload = {
|
||||
"message_id": str(uuid.uuid4()),
|
||||
"content_kind": "file",
|
||||
"text": "",
|
||||
"attachment": {
|
||||
"attachment_id": str(attachment_id),
|
||||
"quarantine_object_key": "quarantine/users/u/file",
|
||||
"quarantine_version_id": "version-1",
|
||||
"quarantine_etag": '"etag-1"',
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": 42,
|
||||
"checksum": "sha256:" + "a" * 64,
|
||||
},
|
||||
}
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
await SafetyClient(settings(), http).check(payload, "request-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_auth_failure_is_dependency_failure() -> None:
|
||||
async def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(401, json={"error": {"code": "service_unauthorized"}})
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
with pytest.raises(DependencyFailure):
|
||||
await SafetyClient(settings(), http).check(
|
||||
{"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "hello"},
|
||||
"request-1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_poll_uses_location_and_rejects_untrusted_location() -> None:
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == f"/internal/safety/v2/messages/tasks/{task_id}"
|
||||
return httpx.Response(
|
||||
403,
|
||||
json={
|
||||
"verdict": "deny",
|
||||
"processing_mode": "standard",
|
||||
"config_version": 2,
|
||||
"rules_version": "2026-08-06",
|
||||
"rule_id": "file.malware_detected",
|
||||
"reason_code": "message_blocked",
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
client = SafetyClient(settings(), http)
|
||||
result = await client.poll(
|
||||
f"/internal/safety/v2/messages/tasks/{task_id}", "request-1"
|
||||
)
|
||||
assert result["_status"] == 403
|
||||
with pytest.raises(DependencyFailure, match="invalid_safety_location"):
|
||||
await client.poll("https://attacker.example/task-1", "request-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_fails_closed_on_malformed_success() -> None:
|
||||
async def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"verdict": "allow"})
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
with pytest.raises(DependencyFailure, match="invalid_safety_response"):
|
||||
await SafetyClient(settings(), http).check(
|
||||
{"message_id": str(uuid.uuid4()), "content_kind": "text", "text": "hello"},
|
||||
"request-1",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openlines_contract_uses_bearer_and_idempotency() -> None:
|
||||
message_id = uuid.uuid4()
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["Authorization"] == "Bearer bitrix-token"
|
||||
assert request.headers["Idempotency-Key"] == str(message_id)
|
||||
assert request.url.path == "/internal/openlines/v1/messages"
|
||||
return httpx.Response(201, json={"status": "accepted"})
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
result = await OpenLinesClient(settings(), http).send(
|
||||
message_id, {"message_id": str(message_id)}, "request-1"
|
||||
)
|
||||
assert result["status"] == "accepted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openlines_payload_gets_fresh_download_url_without_storage_fields() -> None:
|
||||
class FakeS3:
|
||||
calls = 0
|
||||
|
||||
async def presign_get(self, bucket: str, key: str, ttl: int = 300) -> str:
|
||||
self.calls += 1
|
||||
return f"https://download.example/{bucket}/{key}?generation={self.calls}"
|
||||
|
||||
payload = {
|
||||
"message_id": str(uuid.uuid4()),
|
||||
"external_chat_id": str(uuid.uuid4()),
|
||||
"occurred_at": "2026-07-10T12:00:00+00:00",
|
||||
"user": {"id": str(uuid.uuid4()), "display_name": "+79990000000"},
|
||||
"message": {
|
||||
"content_kind": "file",
|
||||
"text": "",
|
||||
"files": [{
|
||||
"attachment_id": str(uuid.uuid4()),
|
||||
"name": "file.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": 42,
|
||||
"_storage_bucket": "attachments",
|
||||
"_object_key": "dialogs/d/file",
|
||||
}],
|
||||
},
|
||||
}
|
||||
s3 = FakeS3()
|
||||
first = await fresh_openlines_payload(payload, s3) # type: ignore[arg-type]
|
||||
second = await fresh_openlines_payload(payload, s3) # type: ignore[arg-type]
|
||||
assert first["occurred_at"] and first["user"]["id"]
|
||||
assert first["message"]["content_kind"] == "file"
|
||||
assert (
|
||||
first["message"]["files"][0]["download_url"]
|
||||
!= second["message"]["files"][0]["download_url"]
|
||||
)
|
||||
assert all(not key.startswith("_") for key in first["message"]["files"][0])
|
||||
@@ -0,0 +1,226 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from pydantic import SecretStr
|
||||
|
||||
from app.main import (
|
||||
EXPECTED_API_DB_REVISION,
|
||||
app,
|
||||
otp_settings,
|
||||
refresh_jwks_cache,
|
||||
websocket_token,
|
||||
)
|
||||
from app.services import SettingsSnapshot
|
||||
|
||||
EXPECTED_PATHS = {
|
||||
"/health/live",
|
||||
"/health/ready",
|
||||
"/api/v1/public/app-config",
|
||||
"/api/v1/public/content",
|
||||
"/api/v1/public/notifications",
|
||||
"/api/v1/public/notification-types",
|
||||
"/api/v1/auth/bootstrap",
|
||||
"/api/v1/consents",
|
||||
"/api/v1/analytics/session-start",
|
||||
"/api/v1/me",
|
||||
"/api/v1/me/documents",
|
||||
"/api/v1/notifications",
|
||||
"/api/v1/notifications/counter",
|
||||
"/api/v1/notifications/{notification_id}",
|
||||
"/api/v1/notifications/{notification_id}/read",
|
||||
"/api/v1/notifications/{notification_id}/hide",
|
||||
"/api/v1/notifications/{notification_id}/buttons/{button_code}",
|
||||
"/api/v1/notifications/{notification_id}/cta",
|
||||
"/api/v1/notifications/{notification_id}/documents/{document_id}/download-url",
|
||||
"/api/v1/uploads/init",
|
||||
"/api/v1/uploads/{draft_id}/complete",
|
||||
"/api/v1/uploads",
|
||||
"/api/v1/uploads/{draft_id}",
|
||||
"/api/v1/documents/{document_id}",
|
||||
"/api/v1/documents/{document_id}/download-url",
|
||||
"/api/v1/dialogs",
|
||||
"/api/v1/dialogs/{dialog_id}",
|
||||
"/api/v1/dialogs/{dialog_id}/messages",
|
||||
"/api/v1/dialogs/{dialog_id}/attachments/init",
|
||||
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete",
|
||||
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url",
|
||||
"/internal/openlines/v1/inbox",
|
||||
"/internal/notifications/v1/notifications",
|
||||
"/internal/notifications/v1/notifications/cancel",
|
||||
"/internal/settings/v1/otp",
|
||||
}
|
||||
|
||||
|
||||
def test_openapi_31_contains_all_http_contracts() -> None:
|
||||
schema = app.openapi()
|
||||
assert schema["openapi"].startswith("3.1.")
|
||||
assert EXPECTED_PATHS <= schema["paths"].keys()
|
||||
assert all(not path.startswith("/internal/safety") for path in schema["paths"])
|
||||
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
|
||||
assert committed["openapi"] == "3.1.0"
|
||||
assert committed["paths"].keys() == schema["paths"].keys()
|
||||
|
||||
|
||||
def test_readiness_expected_revision_matches_alembic_head() -> None:
|
||||
scripts = ScriptDirectory.from_config(Config("alembic.ini"))
|
||||
assert EXPECTED_API_DB_REVISION == scripts.get_current_head()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwks_refresh_loop_recovers_after_startup_race(monkeypatch) -> None:
|
||||
class FakeJWKS:
|
||||
has_keys = False
|
||||
refresh_calls = 0
|
||||
|
||||
async def refresh(self) -> None:
|
||||
self.refresh_calls += 1
|
||||
self.has_keys = True
|
||||
|
||||
jwks = FakeJWKS()
|
||||
test_app = SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
jwks=jwks,
|
||||
settings=SimpleNamespace(jwks_cache_ttl_seconds=300),
|
||||
)
|
||||
)
|
||||
delays: list[int] = []
|
||||
|
||||
async def fake_sleep(delay: int) -> None:
|
||||
delays.append(delay)
|
||||
if len(delays) > 1:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr("app.main.asyncio.sleep", fake_sleep)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await refresh_jwks_cache(test_app)
|
||||
|
||||
assert jwks.refresh_calls == 1
|
||||
assert delays == [5, 300]
|
||||
|
||||
|
||||
def test_websocket_route_is_registered() -> None:
|
||||
assert any(getattr(route, "path", None) == "/api/v1/realtime" for route in app.routes)
|
||||
|
||||
|
||||
def test_notification_http_methods_match_contract() -> None:
|
||||
paths = app.openapi()["paths"]
|
||||
expected = {
|
||||
"/api/v1/public/notifications": {"get"},
|
||||
"/api/v1/public/notification-types": {"get"},
|
||||
"/api/v1/notifications": {"get"},
|
||||
"/api/v1/notifications/counter": {"get"},
|
||||
"/api/v1/notifications/{notification_id}": {"get"},
|
||||
"/api/v1/notifications/{notification_id}/read": {"post"},
|
||||
"/api/v1/notifications/{notification_id}/hide": {"post"},
|
||||
"/api/v1/notifications/{notification_id}/buttons/{button_code}": {"post"},
|
||||
"/api/v1/notifications/{notification_id}/cta": {"post"},
|
||||
(
|
||||
"/api/v1/notifications/{notification_id}/documents/"
|
||||
"{document_id}/download-url"
|
||||
): {"get"},
|
||||
"/api/v1/uploads/init": {"post"},
|
||||
"/api/v1/uploads/{draft_id}/complete": {"post"},
|
||||
"/api/v1/uploads": {"get"},
|
||||
"/api/v1/uploads/{draft_id}": {"delete"},
|
||||
"/internal/notifications/v1/notifications": {"post"},
|
||||
"/internal/notifications/v1/notifications/cancel": {"post"},
|
||||
}
|
||||
for path, methods in expected.items():
|
||||
assert methods <= paths[path].keys()
|
||||
|
||||
|
||||
def test_websocket_accepts_canonical_base64url_jwt_protocol() -> None:
|
||||
jwt = "header.payload.signature"
|
||||
encoded = base64.urlsafe_b64encode(jwt.encode()).decode().rstrip("=")
|
||||
websocket = SimpleNamespace(
|
||||
headers={"sec-websocket-protocol": f"han-chat-v1, han.jwt.{encoded}"},
|
||||
query_params={},
|
||||
)
|
||||
assert websocket_token(websocket) == (jwt, f"han.jwt.{encoded}")
|
||||
|
||||
|
||||
def test_committed_openapi_server_does_not_double_api_prefix() -> None:
|
||||
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
|
||||
assert committed["servers"] == [{"url": "/"}]
|
||||
|
||||
|
||||
def test_public_config_contract_exposes_message_length() -> None:
|
||||
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
|
||||
response = committed["paths"]["/api/v1/public/app-config"]["get"]["responses"]["200"]
|
||||
messages = response["content"]["application/json"]["schema"]["properties"]["messages"]
|
||||
|
||||
assert messages["required"] == ["max_text_length"]
|
||||
assert messages["properties"]["max_text_length"]["maximum"] == 10_000
|
||||
|
||||
|
||||
def test_otp_settings_contract_is_strict_and_complete() -> None:
|
||||
generated = app.openapi()
|
||||
response = generated["paths"]["/internal/settings/v1/otp"]["get"]["responses"]["200"]
|
||||
schema_ref = response["content"]["application/json"]["schema"]["$ref"]
|
||||
schema = generated["components"]["schemas"][schema_ref.rsplit("/", 1)[-1]]
|
||||
|
||||
assert set(schema["required"]) == {
|
||||
"max_send_attempts_per_24h",
|
||||
"min_seconds_between_attempts",
|
||||
"max_verify_attempts",
|
||||
"code_length",
|
||||
"ttl_seconds",
|
||||
"sms_order_timeout_ms",
|
||||
"version",
|
||||
"cache_ttl_seconds",
|
||||
}
|
||||
assert schema["additionalProperties"] is False
|
||||
assert schema["properties"]["code_length"] == {
|
||||
"type": "integer",
|
||||
"maximum": 10.0,
|
||||
"minimum": 4.0,
|
||||
"title": "Code Length",
|
||||
}
|
||||
assert schema["properties"]["ttl_seconds"]["multipleOf"] == 60
|
||||
|
||||
|
||||
async def test_otp_settings_returns_runtime_values_and_supports_etag() -> None:
|
||||
request = SimpleNamespace(
|
||||
headers={"Authorization": "Bearer bridge-token"},
|
||||
app=SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
settings=SimpleNamespace(
|
||||
keycloak_settings_bridge_token=SecretStr("bridge-token")
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
settings = SettingsSnapshot(
|
||||
{
|
||||
"otp.phone.max_send_attempts_per_24h": "3",
|
||||
"otp.phone.min_seconds_between_attempts": "30",
|
||||
"otp.phone.max_verify_attempts": "5",
|
||||
"otp.phone.code_length": "6",
|
||||
"otp.phone.ttl_seconds": "60",
|
||||
"otp.phone.sms_order_timeout_ms": "3000",
|
||||
},
|
||||
"settings-version",
|
||||
)
|
||||
|
||||
response = await otp_settings(request, settings)
|
||||
assert json.loads(response.body) == {
|
||||
"max_send_attempts_per_24h": 3,
|
||||
"min_seconds_between_attempts": 30,
|
||||
"max_verify_attempts": 5,
|
||||
"code_length": 6,
|
||||
"ttl_seconds": 60,
|
||||
"sms_order_timeout_ms": 3000,
|
||||
"version": "settings-version",
|
||||
"cache_ttl_seconds": 60,
|
||||
}
|
||||
|
||||
cached = await otp_settings(request, settings, response.headers["etag"])
|
||||
assert cached.status_code == 304
|
||||
@@ -0,0 +1,153 @@
|
||||
import hashlib
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.main import (
|
||||
app,
|
||||
client_ip,
|
||||
request_trace_id,
|
||||
required_user_audit_context,
|
||||
user_agent_hash,
|
||||
)
|
||||
from app.schemas import Device
|
||||
from app.services import AuditContext, DomainError, audit, device_snapshot
|
||||
|
||||
|
||||
def request(
|
||||
*,
|
||||
peer: str = "172.18.0.5",
|
||||
forwarded: str | None = None,
|
||||
traceparent: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
ux_session_id: str | None = None,
|
||||
trusted: str = "172.16.0.0/12",
|
||||
) -> Request:
|
||||
headers: list[tuple[bytes, bytes]] = []
|
||||
for name, value in (
|
||||
("x-forwarded-for", forwarded),
|
||||
("traceparent", traceparent),
|
||||
("user-agent", user_agent),
|
||||
("x-ux-session-id", ux_session_id),
|
||||
):
|
||||
if value is not None:
|
||||
headers.append((name.encode(), value.encode()))
|
||||
settings = SimpleNamespace(trusted_proxy_cidrs=trusted)
|
||||
app = SimpleNamespace(state=SimpleNamespace(settings=settings))
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"headers": headers,
|
||||
"client": (peer, 12345),
|
||||
"server": ("test", 443),
|
||||
"scheme": "https",
|
||||
"query_string": b"",
|
||||
"app": app,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_client_ip_only_trusts_forwarded_header_from_configured_proxy() -> None:
|
||||
assert client_ip(request(forwarded="203.0.113.10")) == "203.0.113.10"
|
||||
assert (
|
||||
client_ip(request(peer="198.51.100.7", forwarded="203.0.113.10"))
|
||||
== "198.51.100.7"
|
||||
)
|
||||
assert client_ip(request(peer="not-an-ip")) is None
|
||||
|
||||
|
||||
def test_trace_id_uses_valid_w3c_header_and_generates_fallback() -> None:
|
||||
trace_id = "1" * 32
|
||||
assert request_trace_id(request(traceparent=f"00-{trace_id}-{'2' * 16}-01")) == trace_id
|
||||
fallback = request_trace_id(request(traceparent="invalid"))
|
||||
assert len(fallback) == 32
|
||||
int(fallback, 16)
|
||||
|
||||
|
||||
def test_user_agent_is_hashed_and_device_parameters_are_preserved() -> None:
|
||||
agent = "Example Browser/1.0"
|
||||
assert user_agent_hash(request(user_agent=agent)) == hashlib.sha256(agent.encode()).hexdigest()
|
||||
snapshot = device_snapshot(
|
||||
Device(platform="web", app_version="1.2.3", device_id="raw-device-id")
|
||||
)
|
||||
assert snapshot == {
|
||||
"platform": "web",
|
||||
"app_version": "1.2.3",
|
||||
"device_id": "raw-device-id",
|
||||
}
|
||||
|
||||
|
||||
def test_audit_copies_request_context_and_bounded_metadata() -> None:
|
||||
session_id = uuid.uuid4()
|
||||
user_id = uuid.uuid4()
|
||||
context = AuditContext(
|
||||
request_id=str(uuid.uuid4()),
|
||||
trace_id="a" * 32,
|
||||
ux_session_id=session_id,
|
||||
user_agent_hash="b" * 64,
|
||||
client_ip="203.0.113.10",
|
||||
)
|
||||
|
||||
event = audit(
|
||||
"dialog.created",
|
||||
context,
|
||||
user_id,
|
||||
"dialog",
|
||||
uuid.uuid4(),
|
||||
metadata={"status": "open"},
|
||||
)
|
||||
|
||||
assert event.user_id == user_id
|
||||
assert event.ux_session_id == session_id
|
||||
assert event.trace_id == "a" * 32
|
||||
assert event.user_agent_hash == "b" * 64
|
||||
assert event.metadata_json == {"status": "open"}
|
||||
assert event.outcome == "success"
|
||||
assert not hasattr(event, "ip")
|
||||
|
||||
failed = audit("message.failed", context, user_id, outcome="failed")
|
||||
assert failed.outcome == "failed"
|
||||
|
||||
|
||||
def test_post_session_routes_publish_required_ux_header_in_openapi() -> None:
|
||||
operation = app.openapi()["paths"]["/api/v1/dialogs"]["post"]
|
||||
ux_header = next(
|
||||
parameter
|
||||
for parameter in operation["parameters"]
|
||||
if parameter["name"] == "X-Ux-Session-Id"
|
||||
)
|
||||
assert ux_header["in"] == "header"
|
||||
assert ux_header["required"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_audit_context_requires_existing_owned_session() -> None:
|
||||
session_id = uuid.uuid4()
|
||||
user = SimpleNamespace(id=uuid.uuid4())
|
||||
incoming = request(
|
||||
forwarded="203.0.113.10",
|
||||
ux_session_id=str(session_id),
|
||||
user_agent="Example Browser/1.0",
|
||||
)
|
||||
incoming.state.request_id = str(uuid.uuid4())
|
||||
incoming.state.trace_id = "c" * 32
|
||||
incoming.state.user_agent_hash = user_agent_hash(incoming)
|
||||
|
||||
class Db:
|
||||
async def scalar(self, _query):
|
||||
return session_id
|
||||
|
||||
context = await required_user_audit_context(incoming, Db(), user, str(session_id))
|
||||
assert context.ux_session_id == session_id
|
||||
assert context.client_ip == "203.0.113.10"
|
||||
|
||||
missing = request(forwarded="203.0.113.10")
|
||||
missing.state.request_id = str(uuid.uuid4())
|
||||
missing.state.trace_id = "d" * 32
|
||||
missing.state.user_agent_hash = None
|
||||
with pytest.raises(DomainError, match="X-Ux-Session-Id is required"):
|
||||
await required_user_audit_context(missing, Db(), user, "")
|
||||
@@ -0,0 +1,78 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.cli.seed_settings import load_seed
|
||||
from app.services import REQUIRED_SETTINGS
|
||||
|
||||
|
||||
def test_production_like_seed_contains_all_mandatory_settings() -> None:
|
||||
path = Path(__file__).resolve().parents[3] / "deployment/app-settings.production-like.yaml"
|
||||
rows = load_seed(path)
|
||||
|
||||
assert REQUIRED_SETTINGS <= {row["setting_key"] for row in rows}
|
||||
assert all(row["record_status"] == "A" for row in rows)
|
||||
values = {row["setting_key"]: row["setting_value"] for row in rows}
|
||||
assert values["otp.phone.code_length"] == "6"
|
||||
assert values["otp.phone.ttl_seconds"] == "60"
|
||||
assert values["otp.phone.sms_order_timeout_ms"] == "3000"
|
||||
assert values["chat.message.max_length"] == "4000"
|
||||
|
||||
|
||||
def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
|
||||
path = tmp_path / "settings.yaml"
|
||||
path.write_text(
|
||||
"schema_version: 1\nsettings:\n"
|
||||
" bad.integer: {type: integer, value: nope, public: false}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="integer value expected"):
|
||||
load_seed(path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "value", "message"),
|
||||
[
|
||||
("otp.phone.code_length", 3, "between 4 and 10"),
|
||||
("otp.phone.ttl_seconds", 61, "divisible by 60"),
|
||||
("otp.phone.sms_order_timeout_ms", 0, "must be positive"),
|
||||
],
|
||||
)
|
||||
def test_seed_rejects_invalid_otp_settings(
|
||||
tmp_path: Path, key: str, value: int, message: str
|
||||
) -> None:
|
||||
path = tmp_path / "settings.yaml"
|
||||
path.write_text(
|
||||
"schema_version: 1\nsettings:\n"
|
||||
f" {key}: {{type: integer, value: {value}, public: false}}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match=message):
|
||||
load_seed(path)
|
||||
|
||||
|
||||
def test_seed_rejects_public_otp_setting(tmp_path: Path) -> None:
|
||||
path = tmp_path / "settings.yaml"
|
||||
path.write_text(
|
||||
"schema_version: 1\nsettings:\n"
|
||||
" otp.phone.code_length: {type: integer, value: 6, public: true}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="must not be public"):
|
||||
load_seed(path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, 10001])
|
||||
def test_seed_rejects_invalid_chat_message_max_length(tmp_path: Path, value: int) -> None:
|
||||
path = tmp_path / "settings.yaml"
|
||||
path.write_text(
|
||||
"schema_version: 1\nsettings:\n"
|
||||
f" chat.message.max_length: {{type: integer, value: {value}, public: true}}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="value must be between 1 and 10000"):
|
||||
load_seed(path)
|
||||
@@ -0,0 +1,104 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
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,
|
||||
TextMessageRequest,
|
||||
canonical_fingerprint,
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
)
|
||||
from app.services import MESSAGE_SAFETY_REPLIES, safety_reply_message
|
||||
|
||||
|
||||
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"
|
||||
assert canonical_phone({"phone_number": "89999999999"}) is None
|
||||
|
||||
|
||||
def test_message_discriminated_union() -> None:
|
||||
adapter = TypeAdapter(MessageRequest)
|
||||
assert isinstance(
|
||||
adapter.validate_python({"content_kind": "text", "text": "Здравствуйте"}),
|
||||
TextMessageRequest,
|
||||
)
|
||||
assert isinstance(
|
||||
adapter.validate_python(
|
||||
{
|
||||
"content_kind": "file",
|
||||
"attachment_id": str(uuid.uuid4()),
|
||||
"checksum": "sha256:" + "a" * 64,
|
||||
}
|
||||
),
|
||||
FileMessageRequest,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
adapter.validate_python(
|
||||
{"content_kind": "text", "text": "", "attachment_id": str(uuid.uuid4())}
|
||||
)
|
||||
longest = adapter.validate_python({"content_kind": "text", "text": "а" * 10_000})
|
||||
assert len(longest.text) == 10_000
|
||||
with pytest.raises(ValidationError):
|
||||
adapter.validate_python({"content_kind": "text", "text": "а" * 10_001})
|
||||
|
||||
|
||||
def test_message_safety_business_replies_are_content_specific() -> None:
|
||||
assert "переформулировать" in MESSAGE_SAFETY_REPLIES["text"]
|
||||
assert "документ" in MESSAGE_SAFETY_REPLIES["file"]
|
||||
reply = safety_reply_message(uuid.uuid4(), "text")
|
||||
assert reply.sender_type == "company"
|
||||
assert reply.safety_status == "allowed"
|
||||
assert reply.delivery_status == "delivered"
|
||||
|
||||
|
||||
def test_fingerprint_is_canonical_and_user_scoped() -> None:
|
||||
user = uuid.uuid4()
|
||||
first = canonical_fingerprint("post", "/dialogs/{id}", {"id": "1"}, {"b": 2, "a": 1}, user)
|
||||
second = canonical_fingerprint("POST", "/dialogs/{id}", {"id": "1"}, {"a": 1, "b": 2}, user)
|
||||
assert first == second
|
||||
assert first != canonical_fingerprint(
|
||||
"POST", "/dialogs/{id}", {"id": "1"}, {"a": 1, "b": 2}, uuid.uuid4()
|
||||
)
|
||||
|
||||
|
||||
def test_cursor_roundtrip_and_tamper_rejection() -> None:
|
||||
secret = b"test-secret" * 4
|
||||
cursor = encode_cursor({"id": str(uuid.uuid4()), "created_at": "2026-01-01"}, secret)
|
||||
assert decode_cursor(cursor, secret)["created_at"] == "2026-01-01"
|
||||
with pytest.raises(ValueError, match="invalid cursor"):
|
||||
decode_cursor(cursor[:-2] + "aa", secret)
|
||||
|
||||
|
||||
def test_rate_limit_keys_do_not_expose_identity() -> None:
|
||||
key = RateLimiter.key("ip", "203.0.113.7", "public", 60)
|
||||
assert "203.0.113.7" not in key
|
||||
assert key.startswith("han:api:rl:ip:")
|
||||
|
||||
|
||||
def test_circuit_breaker_opens_and_half_opens(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
clock = [10.0]
|
||||
monkeypatch.setattr("app.integrations.time.monotonic", lambda: clock[0])
|
||||
breaker = CircuitBreaker(2, 30)
|
||||
breaker.failure()
|
||||
breaker.failure()
|
||||
assert not breaker.allow()
|
||||
clock[0] = 41.0
|
||||
assert breaker.allow()
|
||||
breaker.success()
|
||||
assert breaker.allow()
|
||||
@@ -0,0 +1,31 @@
|
||||
from app.logging_security import REDACTED, redact_event, sanitize_text
|
||||
|
||||
|
||||
def test_redacts_sensitive_fields_recursively() -> None:
|
||||
event = {
|
||||
"authorization": "Bearer top-secret",
|
||||
"nested": {
|
||||
"database_url": "postgresql://user:password@db/app",
|
||||
"safe": "kept",
|
||||
},
|
||||
}
|
||||
|
||||
redacted = redact_event(None, "info", event)
|
||||
|
||||
assert redacted["authorization"] == REDACTED
|
||||
assert redacted["nested"]["database_url"] == REDACTED
|
||||
assert redacted["nested"]["safe"] == "kept"
|
||||
|
||||
|
||||
def test_redacts_credentials_embedded_in_text() -> None:
|
||||
value = (
|
||||
"POST https://callback-user:callback-password@example.test/cb"
|
||||
"?token=query-secret Authorization=Bearer header-secret"
|
||||
)
|
||||
|
||||
redacted = sanitize_text(value)
|
||||
|
||||
assert "callback-password" not in redacted
|
||||
assert "query-secret" not in redacted
|
||||
assert "header-secret" not in redacted
|
||||
assert redacted.count(REDACTED) == 3
|
||||
@@ -0,0 +1,589 @@
|
||||
import ast
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.db import Document
|
||||
from app.notification_models import (
|
||||
ClientUploadDraft,
|
||||
Notification,
|
||||
NotificationButton,
|
||||
NotificationDocument,
|
||||
NotificationSource,
|
||||
NotificationType,
|
||||
uuid7,
|
||||
)
|
||||
from app.notification_schemas import NotificationCreateRequest
|
||||
from app.notification_service import (
|
||||
_apply_hidden_ttl,
|
||||
apply_read_or_hide,
|
||||
authenticate_source,
|
||||
catalog,
|
||||
create_notification,
|
||||
document_download,
|
||||
expire_notifications,
|
||||
fingerprint,
|
||||
invoke_cta_state,
|
||||
press_button,
|
||||
source_token_hash,
|
||||
validate_create,
|
||||
)
|
||||
from app.realtime import USER_CHANNEL_PREFIX, RealtimeFanout
|
||||
from app.services import AuditContext, DomainError, SettingsSnapshot
|
||||
from app.workers import notification_draft_cleanup_once
|
||||
|
||||
|
||||
def test_notification_migration_does_not_prepare_multiple_sql_commands() -> None:
|
||||
migration = Path("alembic/versions/0008_notification_center_v1.py")
|
||||
tree = ast.parse(migration.read_text(encoding="utf-8"))
|
||||
for call in (node for node in ast.walk(tree) if isinstance(node, ast.Call)):
|
||||
if (
|
||||
isinstance(call.func, ast.Attribute)
|
||||
and call.func.attr == "execute"
|
||||
and call.args
|
||||
and isinstance(call.args[0], ast.Call)
|
||||
and isinstance(call.args[0].func, ast.Attribute)
|
||||
and call.args[0].func.attr == "text"
|
||||
and call.args[0].args
|
||||
and isinstance(call.args[0].args[0], ast.Constant)
|
||||
and isinstance(call.args[0].args[0].value, str)
|
||||
):
|
||||
assert call.args[0].args[0].value.count(";") <= 1
|
||||
|
||||
|
||||
def create_body(**changes: object) -> NotificationCreateRequest:
|
||||
values: dict[str, object] = {
|
||||
"user_id": uuid.uuid4(),
|
||||
"notification_type": "news",
|
||||
"source": "producer_test",
|
||||
"external_id": "event-1",
|
||||
"notification_datetime": "2026-07-27T12:00:00Z",
|
||||
"header": "Новость",
|
||||
"details": {"details_text": "Текст"},
|
||||
}
|
||||
values.update(changes)
|
||||
return NotificationCreateRequest.model_validate(values)
|
||||
|
||||
|
||||
def context() -> AuditContext:
|
||||
return AuditContext("request-1", "trace-1", None, None, None)
|
||||
|
||||
|
||||
def snapshot(default_ttl: int = 3) -> SettingsSnapshot:
|
||||
return SettingsSnapshot(
|
||||
{
|
||||
"notification.hidden.default_ttl_days": str(default_ttl),
|
||||
"notification.center.max_items": "15",
|
||||
},
|
||||
"v1",
|
||||
)
|
||||
|
||||
|
||||
def notification(**changes: object) -> Notification:
|
||||
values: dict[str, object] = {
|
||||
"id": uuid.uuid4(),
|
||||
"user_id": uuid.uuid4(),
|
||||
"notification_type": "news",
|
||||
"source": "producer_test",
|
||||
"external_id": "event-1",
|
||||
"request_fingerprint": "a" * 64,
|
||||
"notification_datetime": datetime.now(UTC),
|
||||
"header": "Header",
|
||||
"lifecycle_status": "active",
|
||||
"visibility": "visible",
|
||||
"is_read": False,
|
||||
"date_expired": None,
|
||||
"close_reason": None,
|
||||
"closed_at": None,
|
||||
}
|
||||
values.update(changes)
|
||||
return Notification(**values)
|
||||
|
||||
|
||||
def kind(**changes: object) -> NotificationType:
|
||||
values: dict[str, object] = {
|
||||
"id": uuid.uuid4(),
|
||||
"code": "news",
|
||||
"contour": "P",
|
||||
"priority": 4,
|
||||
"countable": True,
|
||||
"label": "Новость",
|
||||
"color_token": "info",
|
||||
"icon_code": "news",
|
||||
"cta_text": "Подробнее",
|
||||
"cta_action": "open_detail",
|
||||
"cta_sets_hidden": False,
|
||||
"cta_close_reason": None,
|
||||
"button_primary_code": "gotit",
|
||||
"button_secondary_code": None,
|
||||
"hidden_ttl_days": None,
|
||||
"documents_allowed": False,
|
||||
"hide_on_document_download": False,
|
||||
"required_detail_blocks": [],
|
||||
}
|
||||
values.update(changes)
|
||||
return NotificationType(**values)
|
||||
|
||||
|
||||
class ScalarRows:
|
||||
def __init__(self, rows: list[object]) -> None:
|
||||
self.rows = rows
|
||||
|
||||
def scalars(self) -> "ScalarRows":
|
||||
return self
|
||||
|
||||
def all(self) -> list[object]:
|
||||
return self.rows
|
||||
|
||||
|
||||
def test_uuid7_has_rfc_version_variant_and_embedded_timestamp(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
timestamp_ns = 1_722_340_800_123_000_000
|
||||
monkeypatch.setattr("app.notification_models.time.time_ns", lambda: timestamp_ns)
|
||||
monkeypatch.setattr("app.notification_models.secrets.randbits", lambda bits: (1 << bits) - 1)
|
||||
|
||||
value = uuid7()
|
||||
|
||||
assert value.version == 7
|
||||
assert value.variant == uuid.RFC_4122
|
||||
assert value.int >> 80 == timestamp_ns // 1_000_000
|
||||
|
||||
|
||||
def test_fingerprint_is_canonical_stable_and_sensitive_to_body() -> None:
|
||||
first = create_body()
|
||||
same = NotificationCreateRequest.model_validate(first.model_dump(mode="json"))
|
||||
changed = create_body(header="Другая новость")
|
||||
|
||||
assert fingerprint(first) == fingerprint(same)
|
||||
assert fingerprint(first) != fingerprint(changed)
|
||||
assert len(fingerprint(first)) == 64
|
||||
|
||||
|
||||
def test_create_schema_rejects_read_only_or_unknown_detail_blocks() -> None:
|
||||
with pytest.raises(ValidationError) as pending:
|
||||
create_body(details={"details_text": "Text", "pending_documents": []})
|
||||
with pytest.raises(ValidationError) as unknown:
|
||||
create_body(details={"details_text": "Text", "invented": True})
|
||||
|
||||
assert "pending_documents" in str(pending.value)
|
||||
assert "invented" in str(unknown.value)
|
||||
|
||||
|
||||
def test_catalog_driven_create_validation_covers_required_and_forbidden_fields() -> None:
|
||||
action = SimpleNamespace(required_instance_fields=["details"])
|
||||
docs_kind = kind(
|
||||
required_detail_blocks=["documents"],
|
||||
documents_allowed=True,
|
||||
button_primary_code="gotit",
|
||||
)
|
||||
|
||||
with pytest.raises(DomainError) as error:
|
||||
validate_create(create_body(details={"details_text": "No documents"}), docs_kind, action)
|
||||
|
||||
assert error.value.code == "validation_error"
|
||||
assert error.value.details["fields"] == ["details.documents"]
|
||||
|
||||
with pytest.raises(DomainError) as forbidden:
|
||||
validate_create(
|
||||
create_body(
|
||||
details={"details_text": "Text"},
|
||||
payment_url="https://pay.example/order",
|
||||
),
|
||||
kind(),
|
||||
action,
|
||||
)
|
||||
assert forbidden.value.details["fields"] == ["payment_url"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_create_returns_existing_only_for_matching_fingerprint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
body = create_body()
|
||||
existing = notification(request_fingerprint=fingerprint(body))
|
||||
session = SimpleNamespace(
|
||||
scalar=AsyncMock(return_value=existing),
|
||||
add=Mock(),
|
||||
commit=AsyncMock(),
|
||||
)
|
||||
dto = {"id": str(existing.id)}
|
||||
monkeypatch.setattr(
|
||||
"app.notification_service.notification_dto", AsyncMock(return_value=dto)
|
||||
)
|
||||
|
||||
result, status = await create_notification(
|
||||
session,
|
||||
body,
|
||||
SimpleNamespace(code="producer_test"),
|
||||
SimpleNamespace(),
|
||||
SimpleNamespace(),
|
||||
context(),
|
||||
)
|
||||
assert (result, status) == (dto, 200)
|
||||
|
||||
existing.request_fingerprint = "0" * 64
|
||||
with pytest.raises(DomainError) as conflict:
|
||||
await create_notification(
|
||||
session,
|
||||
body,
|
||||
SimpleNamespace(code="producer_test"),
|
||||
SimpleNamespace(),
|
||||
SimpleNamespace(),
|
||||
context(),
|
||||
)
|
||||
assert conflict.value.code == "notification_conflict"
|
||||
assert conflict.value.status == 409
|
||||
assert conflict.value.details == {"notification_id": str(existing.id)}
|
||||
|
||||
|
||||
def test_hidden_ttl_preserves_existing_expiry_and_uses_type_or_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
now = datetime(2026, 7, 27, 12, tzinfo=UTC)
|
||||
monkeypatch.setattr("app.notification_service.datetime", SimpleNamespace(now=lambda tz: now))
|
||||
existing = now + timedelta(hours=2)
|
||||
with_expiry = notification(date_expired=existing)
|
||||
type_specific = notification()
|
||||
defaulted = notification()
|
||||
|
||||
_apply_hidden_ttl(with_expiry, kind(hidden_ttl_days=9), snapshot())
|
||||
_apply_hidden_ttl(type_specific, kind(hidden_ttl_days=5), snapshot())
|
||||
_apply_hidden_ttl(defaulted, kind(hidden_ttl_days=None), snapshot(3))
|
||||
|
||||
assert with_expiry.date_expired == existing
|
||||
assert type_specific.date_expired == now + timedelta(days=5)
|
||||
assert defaulted.date_expired == now + timedelta(days=3)
|
||||
assert {with_expiry.visibility, type_specific.visibility, defaulted.visibility} == {"hidden"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hide_applies_ttl_without_marking_read(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
item = notification()
|
||||
monkeypatch.setattr(
|
||||
"app.notification_service.owned_notification",
|
||||
AsyncMock(return_value=(item, kind(hidden_ttl_days=2))),
|
||||
)
|
||||
monkeypatch.setattr("app.notification_service.unread_count", AsyncMock(return_value=1))
|
||||
session = SimpleNamespace(add=Mock(), commit=AsyncMock())
|
||||
fanout = SimpleNamespace(publish_user=AsyncMock())
|
||||
|
||||
result = await apply_read_or_hide(
|
||||
session,
|
||||
item.user_id,
|
||||
item.id,
|
||||
"hide",
|
||||
snapshot(),
|
||||
fanout,
|
||||
context(),
|
||||
)
|
||||
|
||||
assert result["visibility"] == "hidden"
|
||||
assert result["is_read"] is False
|
||||
assert item.date_expired is not None
|
||||
event = fanout.publish_user.await_args.args[1]
|
||||
assert event["date_expired"] == item.date_expired
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cta_and_buttons_follow_catalog_lifecycle(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
item = notification()
|
||||
cta_kind = kind(cta_action="open_detail")
|
||||
monkeypatch.setattr(
|
||||
"app.notification_service.owned_notification",
|
||||
AsyncMock(return_value=(item, cta_kind)),
|
||||
)
|
||||
monkeypatch.setattr("app.notification_service.unread_count", AsyncMock(return_value=0))
|
||||
session = SimpleNamespace(add=Mock(), commit=AsyncMock(), scalar=AsyncMock())
|
||||
fanout = SimpleNamespace(publish_user=AsyncMock())
|
||||
|
||||
_, _, cta_result = await invoke_cta_state(
|
||||
session, item.user_id, item.id, snapshot(), fanout, context()
|
||||
)
|
||||
assert item.is_read is True
|
||||
assert item.visibility == "visible"
|
||||
assert item.lifecycle_status == "active"
|
||||
assert cta_result["result"]["action"] == "open_detail"
|
||||
|
||||
button = NotificationButton(
|
||||
id=uuid.uuid4(),
|
||||
code="done",
|
||||
label="Готово",
|
||||
sets_hidden=False,
|
||||
applies_hidden_ttl=False,
|
||||
close_reason="user_done",
|
||||
submits_documents=False,
|
||||
)
|
||||
session.scalar.return_value = button
|
||||
cta_kind.button_primary_code = "done"
|
||||
result = await press_button(
|
||||
session, item.user_id, item.id, "done", snapshot(), fanout, context()
|
||||
)
|
||||
assert result["lifecycle_status"] == "closed"
|
||||
assert result["close_reason"] == "user_done"
|
||||
assert item.closed_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_download_of_any_document_hides_once_and_preserves_expiry(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
expires = datetime.now(UTC) + timedelta(hours=1)
|
||||
item = notification(date_expired=expires)
|
||||
download_kind = kind(
|
||||
code="docs_ready",
|
||||
documents_allowed=True,
|
||||
hide_on_document_download=True,
|
||||
)
|
||||
link = NotificationDocument(
|
||||
id=uuid.uuid4(),
|
||||
notification_id=item.id,
|
||||
document_id=uuid.uuid4(),
|
||||
sort_order=0,
|
||||
download_url_issued_at=None,
|
||||
)
|
||||
document = Document(
|
||||
id=link.document_id,
|
||||
user_id=item.user_id,
|
||||
name="result.pdf",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=42,
|
||||
checksum_sha256="a" * 64,
|
||||
storage_bucket="documents",
|
||||
object_key="documents/result.pdf",
|
||||
sent_at=datetime.now(UTC),
|
||||
)
|
||||
row_result = SimpleNamespace(one_or_none=lambda: (link, document))
|
||||
session = SimpleNamespace(
|
||||
execute=AsyncMock(return_value=row_result),
|
||||
scalar=AsyncMock(return_value=0),
|
||||
add=Mock(),
|
||||
commit=AsyncMock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.notification_service.owned_notification",
|
||||
AsyncMock(return_value=(item, download_kind)),
|
||||
)
|
||||
monkeypatch.setattr("app.notification_service.unread_count", AsyncMock(return_value=0))
|
||||
fanout = SimpleNamespace(publish_user=AsyncMock())
|
||||
s3 = SimpleNamespace(presign_get=AsyncMock(return_value="https://download.example/file"))
|
||||
|
||||
result = await document_download(
|
||||
session,
|
||||
item.user_id,
|
||||
item.id,
|
||||
document.id,
|
||||
snapshot(),
|
||||
s3,
|
||||
fanout,
|
||||
context(),
|
||||
)
|
||||
|
||||
assert result["download_url"] == "https://download.example/file"
|
||||
assert item.is_read is True
|
||||
assert item.visibility == "hidden"
|
||||
assert item.date_expired == expires
|
||||
assert link.download_url_issued_at is not None
|
||||
fanout.publish_user.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_token_hash_and_producer_authentication() -> None:
|
||||
value = "producer-secret"
|
||||
source = NotificationSource(
|
||||
id=uuid.uuid4(),
|
||||
code="producer_test",
|
||||
description="test",
|
||||
token_hash=source_token_hash(value),
|
||||
token_rotated_at=None,
|
||||
record_status="A",
|
||||
)
|
||||
session = SimpleNamespace(execute=AsyncMock(return_value=ScalarRows([source])))
|
||||
|
||||
assert await authenticate_source(session, f"Bearer {value}") is source
|
||||
assert source.token_hash != value
|
||||
with pytest.raises(DomainError) as invalid:
|
||||
await authenticate_source(session, "Bearer wrong")
|
||||
assert (invalid.value.code, invalid.value.status) == ("unauthorized", 401)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_catalog_dto_is_sorted_and_contains_only_public_behavior() -> None:
|
||||
types = [
|
||||
kind(code="news", contour="P", priority=4, button_primary_code="gotit"),
|
||||
kind(
|
||||
code="urgent",
|
||||
contour="P",
|
||||
priority=1,
|
||||
label="Срочно",
|
||||
button_primary_code="done",
|
||||
button_secondary_code="later",
|
||||
),
|
||||
]
|
||||
buttons = [
|
||||
NotificationButton(id=uuid.uuid4(), code="done", label="Готово"),
|
||||
NotificationButton(id=uuid.uuid4(), code="later", label="Позже"),
|
||||
NotificationButton(id=uuid.uuid4(), code="gotit", label="Понятно"),
|
||||
]
|
||||
session = SimpleNamespace(
|
||||
execute=AsyncMock(side_effect=[ScalarRows(types), ScalarRows(buttons)])
|
||||
)
|
||||
|
||||
result = await catalog(session)
|
||||
|
||||
assert [item["code"] for item in result] == ["urgent", "news"]
|
||||
assert result[0]["button_primary"] == {"code": "done", "label": "Готово"}
|
||||
assert result[0]["button_secondary"] == {"code": "later", "label": "Позже"}
|
||||
assert not {
|
||||
"hidden_ttl_days",
|
||||
"cta_sets_hidden",
|
||||
"cta_close_reason",
|
||||
"required_detail_blocks",
|
||||
} & result[0].keys()
|
||||
button_statement = session.execute.await_args_list[1].args[0]
|
||||
assert "notification_buttons.record_status" in str(button_statement)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_realtime_uses_per_user_channel_and_strips_internal_identity() -> None:
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
class PubSub:
|
||||
def __init__(self) -> None:
|
||||
self.channels: tuple[str, ...] = ()
|
||||
|
||||
async def subscribe(self, *channels: str) -> None:
|
||||
self.channels = channels
|
||||
|
||||
async def get_message(self, **_kwargs: object) -> dict[str, str]:
|
||||
return {
|
||||
"data": json.dumps(
|
||||
{
|
||||
"type": "notification.updated",
|
||||
"_user_id": str(user_id),
|
||||
"notification_id": str(uuid.uuid4()),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async def unsubscribe(self, *_channels: str) -> None:
|
||||
return None
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
pubsub = PubSub()
|
||||
redis = SimpleNamespace(publish=AsyncMock(), pubsub=lambda: pubsub)
|
||||
fanout = RealtimeFanout(redis)
|
||||
|
||||
await fanout.publish_user(user_id, {"type": "notification.created"})
|
||||
channel, payload = redis.publish.await_args.args
|
||||
assert channel == USER_CHANNEL_PREFIX + str(user_id)
|
||||
assert json.loads(payload)["_user_id"] == str(user_id)
|
||||
|
||||
stream = cast(
|
||||
AsyncGenerator[dict[str, Any], None],
|
||||
fanout.events(set(), user_id, notifications=True),
|
||||
)
|
||||
event = await anext(stream)
|
||||
await stream.aclose()
|
||||
assert pubsub.channels == (USER_CHANNEL_PREFIX + str(user_id),)
|
||||
assert event["type"] == "notification.updated"
|
||||
assert "_user_id" not in event
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expire_job_is_locked_set_based_and_commits() -> None:
|
||||
session = SimpleNamespace(
|
||||
scalar=AsyncMock(return_value=True),
|
||||
execute=AsyncMock(
|
||||
side_effect=[
|
||||
SimpleNamespace(rowcount=3),
|
||||
SimpleNamespace(rowcount=2),
|
||||
]
|
||||
),
|
||||
add=Mock(),
|
||||
commit=AsyncMock(),
|
||||
)
|
||||
|
||||
assert await expire_notifications(session) == (3, 2)
|
||||
assert session.execute.await_count == 2
|
||||
personal_sql = str(session.execute.await_args_list[0].args[0])
|
||||
guest_sql = str(session.execute.await_args_list[1].args[0])
|
||||
assert "lifecycle_status" in personal_sql and "date_expired" in personal_sql
|
||||
assert "lifecycle_status" in guest_sql and "date_expired" in guest_sql
|
||||
session.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draft_cleanup_deletes_objects_but_keeps_submitted_object(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
abandoned = ClientUploadDraft(
|
||||
id=uuid.uuid4(),
|
||||
user_id=uuid.uuid4(),
|
||||
context_type="notification",
|
||||
context_id=uuid.uuid4(),
|
||||
original_file_name="a.pdf",
|
||||
safe_file_name="a.pdf",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=1,
|
||||
storage_bucket="quarantine",
|
||||
object_key="q/a",
|
||||
quarantine_object_key="q/a",
|
||||
state="draft",
|
||||
)
|
||||
submitted = ClientUploadDraft(
|
||||
id=uuid.uuid4(),
|
||||
user_id=abandoned.user_id,
|
||||
context_type="notification",
|
||||
context_id=abandoned.context_id,
|
||||
original_file_name="b.pdf",
|
||||
safe_file_name="b.pdf",
|
||||
mime_type="application/pdf",
|
||||
size_bytes=1,
|
||||
storage_bucket="attachments",
|
||||
object_key="a/b",
|
||||
quarantine_object_key=None,
|
||||
state="submitted",
|
||||
)
|
||||
session = SimpleNamespace(
|
||||
execute=AsyncMock(side_effect=[ScalarRows([abandoned, submitted]), None, None]),
|
||||
commit=AsyncMock(),
|
||||
)
|
||||
|
||||
class Sessions:
|
||||
async def __aenter__(self) -> object:
|
||||
return session
|
||||
|
||||
async def __aexit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
db = SimpleNamespace(sessions=lambda: Sessions())
|
||||
s3 = SimpleNamespace(delete_quarantine=AsyncMock(), delete=AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
"app.workers.load_settings",
|
||||
AsyncMock(
|
||||
return_value=SettingsSnapshot(
|
||||
{"notification.upload_draft.ttl_days": "7"}, "v1"
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
assert await notification_draft_cleanup_once(db, s3) == 2
|
||||
s3.delete_quarantine.assert_awaited_once_with("q/a")
|
||||
s3.delete.assert_not_awaited()
|
||||
assert session.execute.await_count == 3
|
||||
session.commit.assert_awaited_once()
|
||||
@@ -0,0 +1,25 @@
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
from app import telemetry
|
||||
|
||||
|
||||
def test_telemetry_is_fail_open_without_endpoint(monkeypatch) -> None:
|
||||
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
|
||||
monkeypatch.setattr(telemetry, "_runtime", None)
|
||||
|
||||
assert telemetry.init_telemetry("test-service") is None
|
||||
|
||||
|
||||
def test_structlog_processor_adds_active_trace_context_only() -> None:
|
||||
provider = TracerProvider()
|
||||
tracer = provider.get_tracer("test")
|
||||
event = {"event": "safe", "request_id": "request-1"}
|
||||
|
||||
with tracer.start_as_current_span("operation"):
|
||||
result = telemetry.add_trace_context(None, "info", event)
|
||||
|
||||
assert result["event"] == "safe"
|
||||
assert result["request_id"] == "request-1"
|
||||
assert len(result["trace_id"]) == 32
|
||||
assert len(result["span_id"]) == 16
|
||||
assert set(result) == {"event", "request_id", "trace_id", "span_id"}
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
WORKDIR /service
|
||||
COPY app ./app
|
||||
COPY alembic ./alembic
|
||||
COPY alembic.ini pyproject.toml ./
|
||||
RUN pip install --no-cache-dir .
|
||||
COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"
|
||||
ENTRYPOINT ["/usr/local/bin/han-container-entrypoint"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,30 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = postgresql+asyncpg://unused
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
[handlers]
|
||||
keys = console
|
||||
[formatters]
|
||||
keys = generic
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
@@ -0,0 +1,42 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from alembic import context
|
||||
|
||||
from app.models import Base
|
||||
from app.postgres import create_postgres_engine
|
||||
|
||||
config = context.config
|
||||
url = os.environ["BITRIX_DATABASE_URL"]
|
||||
config.set_main_option("sqlalchemy.url", url.replace("%", "%%"))
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
include_schemas=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run(connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata, include_schemas=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_online() -> None:
|
||||
engine = create_postgres_engine(url)
|
||||
async with engine.connect() as connection:
|
||||
await connection.run_sync(do_run)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_offline()
|
||||
else:
|
||||
asyncio.run(run_online())
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
"""Create durable OAuth, mapping, inbox, outbox, setup and audit storage."""
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.models import Base
|
||||
|
||||
revision = "0001_bitrix_local"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
op.execute("CREATE SCHEMA IF NOT EXISTS bitrix_local")
|
||||
Base.metadata.create_all(bind=bind, checkfirst=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
Base.metadata.drop_all(bind=op.get_bind(), checkfirst=True)
|
||||
@@ -0,0 +1 @@
|
||||
"""HAN Bitrix24 Open Lines adapter."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
SCHEMA = "bitrix_local"
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Common:
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||
record_status: Mapped[str] = mapped_column(String(1), default="A")
|
||||
status_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
status_change_reason: Mapped[str | None] = mapped_column(String(255))
|
||||
|
||||
|
||||
class PortalInstallation(Common, Base):
|
||||
__tablename__ = "portal_installations"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_portal_active_domain",
|
||||
"domain",
|
||||
unique=True,
|
||||
postgresql_where=text("record_status = 'A'"),
|
||||
),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
member_id: Mapped[str] = mapped_column(String(128), unique=True)
|
||||
domain: Mapped[str] = mapped_column(String(255))
|
||||
client_endpoint: Mapped[str] = mapped_column(String(1024))
|
||||
access_ciphertext: Mapped[str] = mapped_column(Text)
|
||||
access_nonce: Mapped[str] = mapped_column(String(64))
|
||||
refresh_ciphertext: Mapped[str] = mapped_column(Text)
|
||||
refresh_nonce: Mapped[str] = mapped_column(String(64))
|
||||
application_ciphertext: Mapped[str] = mapped_column(Text)
|
||||
application_nonce: Mapped[str] = mapped_column(String(64))
|
||||
key_version: Mapped[str] = mapped_column(String(32))
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
scope: Mapped[str | None] = mapped_column(Text)
|
||||
install_status: Mapped[str] = mapped_column(String(32), default="installed")
|
||||
setup_status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||
last_refresh_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class ConnectorSetup(Common, Base):
|
||||
__tablename__ = "connector_setup"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("portal_id", "connector_id", "line_id"),
|
||||
Index("ix_setup_retry", "next_retry_at", "attempt_count"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
portal_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.portal_installations.id")
|
||||
)
|
||||
connector_id: Mapped[str] = mapped_column(String(64))
|
||||
line_id: Mapped[str] = mapped_column(String(32))
|
||||
registered: Mapped[bool] = mapped_column(default=False)
|
||||
activated: Mapped[bool] = mapped_column(default=False)
|
||||
bindings_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
desired_version: Mapped[str] = mapped_column(String(32), default="1")
|
||||
observed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class DialogSession(Common, Base):
|
||||
__tablename__ = "dialog_sessions"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_dialog_active_external_chat",
|
||||
"external_chat_id",
|
||||
unique=True,
|
||||
postgresql_where=text("record_status = 'A'"),
|
||||
),
|
||||
Index("ix_dialog_bitrix_chat", "bitrix_chat_id"),
|
||||
Index("ix_dialog_session", "session_id"),
|
||||
Index("ix_dialog_status_updated", "status", "updated_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
bitrix_chat_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||
session_id: Mapped[str | None] = mapped_column(String(255))
|
||||
portal_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.portal_installations.id")
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(16), default="open")
|
||||
|
||||
|
||||
class InboxEvent(Common, Base):
|
||||
__tablename__ = "inbox_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("event_id"),
|
||||
Index(
|
||||
"uq_inbox_chat_message",
|
||||
"external_chat_id",
|
||||
"bitrix_message_id",
|
||||
unique=True,
|
||||
postgresql_where=text("bitrix_message_id IS NOT NULL"),
|
||||
),
|
||||
Index("ix_inbox_worker", "status", "next_attempt_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
event_id: Mapped[str] = mapped_column(String(255))
|
||||
event_type: Mapped[str] = mapped_column(String(64))
|
||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||
payload_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||
normalized_json: Mapped[dict] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(32), default="received")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
api_ack_status: Mapped[str | None] = mapped_column(String(32))
|
||||
delivery_ack_status: Mapped[str | None] = mapped_column(String(32))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class OutboundMessage(Common, Base):
|
||||
__tablename__ = "outbound_messages"
|
||||
__table_args__ = (Index("ix_outbound_worker", "status", "next_attempt_at"), {"schema": SCHEMA})
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), unique=True)
|
||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
request_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||
payload_json: Mapped[dict] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(32), default="received")
|
||||
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||
response_json: Mapped[dict | None] = mapped_column(JSON)
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class DeliveryAckOutbox(Common, Base):
|
||||
__tablename__ = "delivery_ack_outbox"
|
||||
__table_args__ = (Index("ix_ack_worker", "status", "next_attempt_at"), {"schema": SCHEMA})
|
||||
inbox_event_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.inbox_events.id"), unique=True
|
||||
)
|
||||
payload_json: Mapped[dict] = mapped_column(JSON)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error_code: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
class InstallRun(Common, Base):
|
||||
__tablename__ = "install_runs"
|
||||
__table_args__ = ({"schema": SCHEMA},)
|
||||
portal_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
status: Mapped[str] = mapped_column(String(32))
|
||||
result_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
|
||||
class AuditEvent(Common, Base):
|
||||
__tablename__ = "audit_events"
|
||||
__table_args__ = (Index("ix_audit_created", "created_at"), {"schema": SCHEMA})
|
||||
event_type: Mapped[str] = mapped_column(String(128))
|
||||
actor_type: Mapped[str] = mapped_column(String(32), default="system")
|
||||
safe_details: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
for name in ${HAN_SECRET_VARS:-}; do
|
||||
case "$name" in
|
||||
""|[0-9]*|*[!A-Z0-9_]*)
|
||||
echo "container secrets: invalid variable name" >&2
|
||||
exit 64
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
eval "file=\${${name}_FILE:-}"
|
||||
if [ -z "$file" ] || [ ! -r "$file" ]; then
|
||||
echo "container secrets: missing file for $name" >&2
|
||||
exit 66
|
||||
fi
|
||||
value=$(cat "$file")
|
||||
export "$name=$value"
|
||||
unset "${name}_FILE"
|
||||
done
|
||||
|
||||
unset HAN_SECRET_VARS
|
||||
exec "$@"
|
||||
@@ -0,0 +1,104 @@
|
||||
openapi: 3.1.0
|
||||
info: {title: HAN Bitrix24 Local App, version: 1.0.0}
|
||||
paths:
|
||||
/bitrix/handler:
|
||||
get: {responses: {"200": {description: Callback probe}}}
|
||||
post:
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json: {schema: {type: object}}
|
||||
application/x-www-form-urlencoded: {schema: {type: object}}
|
||||
multipart/form-data: {schema: {type: object}}
|
||||
responses:
|
||||
"200": {description: Duplicate or ignored callback}
|
||||
"202": {description: Durably accepted callback}
|
||||
"400": {description: Invalid callback}
|
||||
"403": {description: Invalid application token}
|
||||
/bitrix/install:
|
||||
get: {responses: {"200": {description: Install probe}}}
|
||||
post:
|
||||
responses:
|
||||
"200": {description: OAuth saved and setup attempted}
|
||||
"400": {description: Invalid install callback}
|
||||
/bitrix/placement:
|
||||
get: {responses: {"200": {description: Connector placement HTML}}}
|
||||
/health/live:
|
||||
get: {responses: {"200": {description: Live}}}
|
||||
/health/ready:
|
||||
get: {responses: {"200": {description: Ready}, "503": {description: Not ready}}}
|
||||
/internal/openlines/v1/messages:
|
||||
post:
|
||||
security: [{BearerAuth: []}]
|
||||
parameters:
|
||||
- {name: Idempotency-Key, in: header, required: true, schema: {type: string}}
|
||||
- {$ref: "#/components/parameters/RequestId"}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json: {schema: {$ref: "#/components/schemas/OutboundMessage"}}
|
||||
responses:
|
||||
"201": {description: Delivered}
|
||||
"200": {description: Idempotent duplicate}
|
||||
"400": {description: Invalid request}
|
||||
"401": {description: Unauthorized}
|
||||
"409": {description: Idempotency key reused}
|
||||
"503": {description: Dependency unavailable or ambiguous delivery}
|
||||
/internal/openlines/v1/dialogs/{external_chat_id}:
|
||||
get:
|
||||
security: [{BearerAuth: []}]
|
||||
parameters:
|
||||
- {name: external_chat_id, in: path, required: true, schema: {type: string, format: uuid}}
|
||||
responses:
|
||||
"200": {description: Active dialog mapping}
|
||||
"404": {description: Mapping not found}
|
||||
/internal/openlines/v1/status:
|
||||
get:
|
||||
security: [{BearerAuth: []}]
|
||||
responses: {"200": {description: Safe adapter status}}
|
||||
/internal/openlines/v1/setup/retry:
|
||||
post:
|
||||
security: [{BearerAuth: []}]
|
||||
responses: {"200": {description: Idempotent setup reconcile result}}
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth: {type: http, scheme: bearer}
|
||||
parameters:
|
||||
RequestId: {name: X-Request-ID, in: header, required: false, schema: {type: string}}
|
||||
schemas:
|
||||
OutboundFile:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [attachment_id, name, mime_type, size_bytes, download_url]
|
||||
properties:
|
||||
attachment_id: {type: string, format: uuid}
|
||||
name: {type: string, maxLength: 255}
|
||||
mime_type: {type: string, maxLength: 255}
|
||||
size_bytes: {type: integer, minimum: 1, maximum: 5242880}
|
||||
download_url: {type: string, maxLength: 4096}
|
||||
OutboundMessage:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [message_id, external_chat_id, occurred_at, user, message]
|
||||
properties:
|
||||
message_id: {type: string, format: uuid}
|
||||
external_chat_id: {type: string, format: uuid}
|
||||
occurred_at: {type: string, format: date-time}
|
||||
user:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [id, display_name]
|
||||
properties:
|
||||
id: {type: string, format: uuid}
|
||||
display_name: {type: string, maxLength: 255}
|
||||
message:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [content_kind, text, files]
|
||||
properties:
|
||||
content_kind: {type: string, enum: [text, file]}
|
||||
text: {type: string, maxLength: 10000}
|
||||
files:
|
||||
type: array
|
||||
maxItems: 1
|
||||
items: {$ref: "#/components/schemas/OutboundFile"}
|
||||
@@ -0,0 +1,33 @@
|
||||
[project]
|
||||
name = "han-bitrix-local-app"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"alembic>=1.16,<2",
|
||||
"asyncpg>=0.30,<1",
|
||||
"cryptography>=45,<46",
|
||||
"fastapi>=0.116,<1",
|
||||
"httpx>=0.28,<1",
|
||||
"pydantic-settings>=2.10,<3",
|
||||
"python-multipart>=0.0.20,<1",
|
||||
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||
"uvicorn[standard]>=0.35,<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.4,<9", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
@@ -0,0 +1,280 @@
|
||||
import base64
|
||||
import os
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
os.environ.setdefault("BITRIX_DATABASE_URL", "postgresql://unused/unused")
|
||||
os.environ.setdefault("BITRIX_CLIENT_ID", "client")
|
||||
os.environ.setdefault("BITRIX_CLIENT_SECRET", "secret")
|
||||
os.environ.setdefault("BITRIX_APPLICATION_TOKEN", "application-token")
|
||||
os.environ.setdefault("BITRIX_INTERNAL_API_TOKEN", "internal-token-32-characters-long")
|
||||
os.environ.setdefault("BITRIX_API_FORWARD_URL", "http://api/internal/openlines/v1/inbox")
|
||||
os.environ.setdefault("BITRIX_API_FORWARD_TOKEN", "forward-token-32-characters-long")
|
||||
os.environ.setdefault(
|
||||
"BITRIX_TOKEN_ENCRYPTION_KEY",
|
||||
base64.urlsafe_b64encode(b"k" * 32).decode().rstrip("="),
|
||||
)
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.main import (
|
||||
BitrixClient,
|
||||
TokenCipher,
|
||||
canonical_fingerprint,
|
||||
normalize_event,
|
||||
resolve_inbound_file_urls,
|
||||
retry_delay,
|
||||
safely_retryable,
|
||||
validate_portal,
|
||||
)
|
||||
|
||||
|
||||
def test_token_cipher_binds_aad():
|
||||
cipher = TokenCipher(os.environ["BITRIX_TOKEN_ENCRYPTION_KEY"], "v1")
|
||||
ciphertext, nonce = cipher.encrypt("secret", "member", "han0107.bitrix24.ru", "access")
|
||||
assert cipher.decrypt(ciphertext, nonce, "member", "han0107.bitrix24.ru", "access") == "secret"
|
||||
with pytest.raises(Exception):
|
||||
cipher.decrypt(ciphertext, nonce, "other", "han0107.bitrix24.ru", "access")
|
||||
|
||||
|
||||
def test_normalize_message_and_finish():
|
||||
external = str(uuid.uuid4())
|
||||
message = normalize_event(
|
||||
{
|
||||
"event": "ONIMCONNECTORMESSAGEADD",
|
||||
"data": {
|
||||
"MESSAGES": [
|
||||
{
|
||||
"im": {"chat_id": 1807, "message_id": 86497},
|
||||
"chat": {"id": external},
|
||||
"message": {"text": "Ответ", "files": []},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
assert message["event_type"] == "message.new"
|
||||
assert message["external_chat_id"] == external
|
||||
assert message["bitrix_message_id"] == "86497"
|
||||
assert message["message"]["text"] == "Ответ"
|
||||
closed = normalize_event(
|
||||
{"event": "ONIMCONNECTORDIALOGFINISH", "data": {"external_chat_id": external}}
|
||||
)
|
||||
assert closed["event_type"] == "dialog.closed"
|
||||
|
||||
|
||||
def test_normalize_message_removes_bitrix_sender_prefix():
|
||||
external = str(uuid.uuid4())
|
||||
message = normalize_event(
|
||||
{
|
||||
"event": "ONIMCONNECTORMESSAGEADD",
|
||||
"data": {
|
||||
"MESSAGES": [
|
||||
{
|
||||
"im": {"message_id": 86498},
|
||||
"chat": {"id": external},
|
||||
"message": {
|
||||
"text": "[b]Антон Пичугин:[/b] [br]опять ты?",
|
||||
"files": [],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert message["message"]["text"] == "опять ты?"
|
||||
|
||||
|
||||
def test_normalize_bitrix_file_uses_download_url_and_infers_mime_type():
|
||||
external = str(uuid.uuid4())
|
||||
message = normalize_event(
|
||||
{
|
||||
"event": "ONIMCONNECTORMESSAGEADD",
|
||||
"data": {
|
||||
"MESSAGES": [
|
||||
{
|
||||
"im": {"chat_id": 1807, "message_id": 86498},
|
||||
"chat": {"id": external},
|
||||
"message": {
|
||||
"text": "",
|
||||
"files": [
|
||||
{
|
||||
"name": "image.png",
|
||||
"type": "image",
|
||||
"size": 941380,
|
||||
"urlDownload": "https://portal.bitrix24.ru/download/file.png",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert message["message"]["files"] == [
|
||||
{
|
||||
"name": "image.png",
|
||||
"mime_type": "image/png",
|
||||
"size_bytes": 941380,
|
||||
"download_url": "https://portal.bitrix24.ru/download/file.png",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_normalize_bitrix_file_uses_open_lines_download_link_and_mime():
|
||||
external = str(uuid.uuid4())
|
||||
message = normalize_event(
|
||||
{
|
||||
"event": "ONIMCONNECTORMESSAGEADD",
|
||||
"data": {
|
||||
"MESSAGES": [
|
||||
{
|
||||
"im": {"chat_id": 1807, "message_id": 86499},
|
||||
"chat": {"id": external},
|
||||
"message": {
|
||||
"text": "",
|
||||
"files": [
|
||||
{
|
||||
"name": "diploma.jpg",
|
||||
"type": "image",
|
||||
"mime": "image/jpeg",
|
||||
"size": 236934,
|
||||
"downloadLink": (
|
||||
"https://portal.bitrix24.ru/download/diploma.jpg"
|
||||
),
|
||||
"link": "https://portal.bitrix24.ru/view/diploma.jpg",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert message["message"]["files"] == [
|
||||
{
|
||||
"name": "diploma.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
"size_bytes": 236934,
|
||||
"download_url": "https://portal.bitrix24.ru/download/diploma.jpg",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_inbound_file_url_from_bitrix_file_id(monkeypatch):
|
||||
class SessionContext:
|
||||
async def __aenter__(self):
|
||||
return object()
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
class Bitrix:
|
||||
async def call(self, portal, method, params):
|
||||
assert portal == "portal"
|
||||
assert method == "im.v2.File.download"
|
||||
assert params == {"id": "5155"}
|
||||
return {"downloadUrl": "https://portal.bitrix24.ru/download/file.png"}
|
||||
|
||||
async def fake_active_portal(_session):
|
||||
return "portal"
|
||||
|
||||
monkeypatch.setattr("app.main.active_portal", fake_active_portal)
|
||||
app = SimpleNamespace(
|
||||
state=SimpleNamespace(sessions=lambda: SessionContext(), bitrix=Bitrix())
|
||||
)
|
||||
payload = {
|
||||
"message": {
|
||||
"files": [
|
||||
{
|
||||
"name": "image.png",
|
||||
"mime_type": "image/png",
|
||||
"size_bytes": 941380,
|
||||
"download_url": "",
|
||||
"_bitrix_file_id": "5155",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
await resolve_inbound_file_urls(app, payload)
|
||||
|
||||
assert payload["message"]["files"] == [
|
||||
{
|
||||
"name": "image.png",
|
||||
"mime_type": "image/png",
|
||||
"size_bytes": 941380,
|
||||
"download_url": "https://portal.bitrix24.ru/download/file.png",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_fingerprint_ignores_signed_query_and_portal_validation():
|
||||
payload = {"message": {"files": [{"download_url": "https://s3/object?sig=one"}]}}
|
||||
other = {"message": {"files": [{"download_url": "https://s3/object?sig=two"}]}}
|
||||
assert canonical_fingerprint(payload) == canonical_fingerprint(other)
|
||||
validate_portal(
|
||||
"han0107.bitrix24.ru",
|
||||
"https://han0107.bitrix24.ru/rest/",
|
||||
"han0107.bitrix24.ru",
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
validate_portal("evil.example", "https://evil.example/rest/", "han0107.bitrix24.ru")
|
||||
assert 0 <= retry_delay(4, 300) <= 8
|
||||
|
||||
|
||||
def test_network_timeouts_are_retryable():
|
||||
assert safely_retryable(TimeoutError("Delivery operation timed out"))
|
||||
assert safely_retryable(httpx.ReadTimeout("Bitrix response timed out"))
|
||||
assert safely_retryable(httpx.ConnectTimeout("Bitrix connection timed out"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bitrix_call_refreshes_and_retries_once_after_401():
|
||||
auth_values: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
auth = parse_qs(request.content.decode())["auth"][0]
|
||||
auth_values.append(auth)
|
||||
if auth == "old-access":
|
||||
return httpx.Response(401, request=request)
|
||||
return httpx.Response(200, json={"result": {"ok": True}}, request=request)
|
||||
|
||||
class Cipher:
|
||||
@staticmethod
|
||||
def decrypt(ciphertext, *_):
|
||||
return ciphertext
|
||||
|
||||
portal = SimpleNamespace(
|
||||
access_ciphertext="old-access",
|
||||
access_nonce="nonce",
|
||||
member_id="member",
|
||||
domain="han0107.bitrix24.ru",
|
||||
expires_at=None,
|
||||
)
|
||||
settings = SimpleNamespace(bitrix_http_max_concurrency=2)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
client = BitrixClient(http, settings, Cipher(), sessions=None)
|
||||
refresh_calls: list[bool] = []
|
||||
|
||||
async def ensure_fresh(_, *, force=False, stale_access_ciphertext=None):
|
||||
refresh_calls.append(force)
|
||||
if force:
|
||||
assert stale_access_ciphertext == "old-access"
|
||||
portal.access_ciphertext = "new-access"
|
||||
|
||||
client.ensure_fresh = ensure_fresh
|
||||
result = await client.call(portal, "imconnector.send.messages", {"MESSAGE": "test"})
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert auth_values == ["old-access", "new-access"]
|
||||
assert refresh_calls == [False, True]
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM python:3.12-slim
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
WORKDIR /service
|
||||
COPY app ./app
|
||||
COPY alembic ./alembic
|
||||
COPY alembic.ini pyproject.toml ./
|
||||
RUN pip install --no-cache-dir .
|
||||
COPY --chmod=0555 container-entrypoint.sh /usr/local/bin/han-container-entrypoint
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=2)"
|
||||
ENTRYPOINT ["/usr/local/bin/han-container-entrypoint"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,30 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = postgresql+asyncpg://unused
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
[handlers]
|
||||
keys = console
|
||||
[formatters]
|
||||
keys = generic
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
@@ -0,0 +1,38 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from alembic import context
|
||||
from app.postgres import create_postgres_engine
|
||||
|
||||
config = context.config
|
||||
database_url = os.environ["BITRIX_SYNC_DATABASE_URL"]
|
||||
config.set_main_option(
|
||||
"sqlalchemy.url",
|
||||
database_url.replace("%", "%%"),
|
||||
)
|
||||
target_metadata = None
|
||||
|
||||
|
||||
def run_offline() -> None:
|
||||
context.configure(url=config.get_main_option("sqlalchemy.url"), literal_binds=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_online() -> None:
|
||||
engine = create_postgres_engine(database_url)
|
||||
async with engine.connect() as connection:
|
||||
await connection.run_sync(do_run)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def do_run(connection) -> None:
|
||||
context.configure(connection=connection)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_offline()
|
||||
else:
|
||||
asyncio.run(run_online())
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
"""Establish the bitrix-sync connectivity-stub migration baseline."""
|
||||
|
||||
revision = "0001_sync_baseline"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# The connectivity stub deliberately owns no runtime tables.
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
"""HAN bitrix-sync DB connectivity stub."""
|
||||
@@ -0,0 +1,315 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Annotated, Protocol
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Header, HTTPException, Request
|
||||
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
|
||||
|
||||
from app.postgres import create_postgres_engine
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(extra="ignore")
|
||||
app_env: str = "production-like"
|
||||
bitrix_sync_enabled: bool = True
|
||||
bitrix_sync_database_url: str | None = None
|
||||
bitrix_sync_service_token: str = Field(min_length=16)
|
||||
bitrix_sync_db_check_interval_sec: float = Field(default=60, ge=0.05)
|
||||
bitrix_sync_db_check_timeout_sec: float = Field(default=5, ge=0.05)
|
||||
bitrix_sync_db_check_jitter_ratio: float = Field(default=0.1, ge=0, le=0.5)
|
||||
bitrix_sync_db_retry_base_sec: float = Field(default=5, ge=0.05)
|
||||
bitrix_sync_db_retry_max_sec: float = Field(default=60, ge=0.05)
|
||||
bitrix_sync_ready_max_staleness_sec: float = Field(default=150, ge=1)
|
||||
bitrix_sync_db_pool_size: int = Field(default=2, ge=1, le=5)
|
||||
bitrix_sync_db_pool_recycle_sec: int = Field(default=300, ge=30)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_enabled(self) -> Settings:
|
||||
if self.bitrix_sync_enabled and not self.bitrix_sync_database_url:
|
||||
raise ValueError("BITRIX_SYNC_DATABASE_URL is required when sync is enabled")
|
||||
return self
|
||||
|
||||
|
||||
@dataclass
|
||||
class Snapshot:
|
||||
state: str
|
||||
started_at: datetime
|
||||
last_started_at: datetime | None = None
|
||||
last_finished_at: datetime | None = None
|
||||
last_success_at: datetime | None = None
|
||||
success: bool | None = None
|
||||
duration_ms: int | None = None
|
||||
error_code: str | None = None
|
||||
consecutive_failures: int = 0
|
||||
next_check_at: datetime | None = None
|
||||
worker_running: bool = False
|
||||
|
||||
|
||||
class Probe(Protocol):
|
||||
async def check(self) -> None: ...
|
||||
async def close(self) -> None: ...
|
||||
|
||||
|
||||
class DatabaseProbe:
|
||||
def __init__(self, engine: AsyncEngine) -> None:
|
||||
self.engine = engine
|
||||
|
||||
async def check(self) -> None:
|
||||
async with self.engine.connect() as connection:
|
||||
result = await connection.scalar(text("SELECT 1"))
|
||||
if result != 1:
|
||||
raise RuntimeError("unexpected_result")
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.engine.dispose()
|
||||
|
||||
|
||||
def classify_error(exc: Exception) -> str:
|
||||
name = type(exc).__name__.lower()
|
||||
text_value = str(exc).lower()
|
||||
if isinstance(exc, TimeoutError):
|
||||
return "db_query_timeout"
|
||||
if "auth" in name or "password" in text_value:
|
||||
return "db_auth_failed"
|
||||
if "ssl" in name or "tls" in text_value or "certificate" in text_value:
|
||||
return "db_tls_failed"
|
||||
if str(exc) == "unexpected_result":
|
||||
return "unexpected_result"
|
||||
return "db_unavailable"
|
||||
|
||||
|
||||
def jsonable(value):
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat().replace("+00:00", "Z")
|
||||
return value
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None, probe: Probe | None = None) -> FastAPI:
|
||||
cfg = settings or Settings()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
app.state.snapshot = Snapshot(
|
||||
state="disabled" if not cfg.bitrix_sync_enabled else "starting",
|
||||
started_at=utcnow(),
|
||||
)
|
||||
app.state.stop = asyncio.Event()
|
||||
app.state.probe = probe
|
||||
app.state.loop_task = None
|
||||
if cfg.bitrix_sync_enabled:
|
||||
if app.state.probe is None:
|
||||
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,
|
||||
server_settings={"application_name": "han-bitrix-sync"},
|
||||
)
|
||||
app.state.probe = DatabaseProbe(engine)
|
||||
await run_probe(app)
|
||||
app.state.snapshot.worker_running = True
|
||||
app.state.loop_task = asyncio.create_task(
|
||||
periodic_loop(app), name="db-connectivity-probe"
|
||||
)
|
||||
yield
|
||||
app.state.snapshot.state = "stopping"
|
||||
app.state.stop.set()
|
||||
if app.state.loop_task:
|
||||
app.state.loop_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await app.state.loop_task
|
||||
if app.state.probe:
|
||||
await app.state.probe.close()
|
||||
|
||||
app = FastAPI(
|
||||
title="HAN Bitrix Sync Connectivity Stub",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url=None if cfg.app_env != "test" else "/docs",
|
||||
)
|
||||
app.state.settings = cfg
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_id(request: Request, call_next):
|
||||
request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = request.state.request_id
|
||||
return response
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_error(_: Request, exc: HTTPException):
|
||||
return JSONResponse(status_code=exc.status_code, content=exc.detail)
|
||||
|
||||
@app.get("/health/live")
|
||||
async def live():
|
||||
return {"status": "live"}
|
||||
|
||||
@app.get("/health/ready")
|
||||
async def ready(request: Request):
|
||||
snapshot: Snapshot = request.app.state.snapshot
|
||||
if not cfg.bitrix_sync_enabled:
|
||||
return JSONResponse({"status": "not_ready", "reason": "sync_disabled"}, status_code=503)
|
||||
age = (
|
||||
(utcnow() - snapshot.last_success_at).total_seconds()
|
||||
if snapshot.last_success_at
|
||||
else None
|
||||
)
|
||||
max_age = max(
|
||||
cfg.bitrix_sync_ready_max_staleness_sec,
|
||||
2 * cfg.bitrix_sync_db_check_interval_sec * (1 + cfg.bitrix_sync_db_check_jitter_ratio),
|
||||
)
|
||||
is_ready = (
|
||||
snapshot.state == "healthy"
|
||||
and snapshot.worker_running
|
||||
and age is not None
|
||||
and age <= max_age
|
||||
)
|
||||
if is_ready:
|
||||
return {
|
||||
"status": "ready",
|
||||
"mode": "db_connectivity_stub",
|
||||
"database": {
|
||||
"status": "ok",
|
||||
"last_success_at": jsonable(snapshot.last_success_at),
|
||||
"age_seconds": round(age or 0, 3),
|
||||
},
|
||||
}
|
||||
reason = "worker_not_running" if not snapshot.worker_running else "database_unavailable"
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "not_ready",
|
||||
"reason": reason,
|
||||
"database": {
|
||||
"status": "down",
|
||||
"last_success_at": jsonable(snapshot.last_success_at),
|
||||
"consecutive_failures": snapshot.consecutive_failures,
|
||||
},
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
@app.get("/internal/sync/v1/status")
|
||||
async def status(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
):
|
||||
candidate = (
|
||||
authorization[7:] if authorization and authorization.startswith("Bearer ") else ""
|
||||
)
|
||||
if not hmac.compare_digest(candidate, cfg.bitrix_sync_service_token):
|
||||
raise HTTPException(
|
||||
401,
|
||||
{
|
||||
"error": {
|
||||
"code": "service_unauthorized",
|
||||
"message": "Service authentication failed",
|
||||
"request_id": request.state.request_id,
|
||||
"details": {},
|
||||
}
|
||||
},
|
||||
)
|
||||
snapshot: Snapshot = request.app.state.snapshot
|
||||
last_check = None
|
||||
if snapshot.last_started_at:
|
||||
last_check = {
|
||||
"started_at": jsonable(snapshot.last_started_at),
|
||||
"finished_at": jsonable(snapshot.last_finished_at),
|
||||
"success": snapshot.success,
|
||||
"duration_ms": snapshot.duration_ms,
|
||||
"error_code": snapshot.error_code,
|
||||
}
|
||||
next_in = (
|
||||
max(0, (snapshot.next_check_at - utcnow()).total_seconds())
|
||||
if snapshot.next_check_at
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"service": "bitrix-sync",
|
||||
"enabled": cfg.bitrix_sync_enabled,
|
||||
"mode": "db_connectivity_stub",
|
||||
"crm_sync_implemented": False,
|
||||
"state": snapshot.state,
|
||||
"started_at": jsonable(snapshot.started_at),
|
||||
"last_check": last_check,
|
||||
"last_success_at": jsonable(snapshot.last_success_at),
|
||||
"consecutive_failures": snapshot.consecutive_failures,
|
||||
"next_check_in_seconds": round(next_in, 3) if next_in is not None else None,
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def run_probe(app: FastAPI) -> None:
|
||||
cfg: Settings = app.state.settings
|
||||
snapshot: Snapshot = app.state.snapshot
|
||||
snapshot.last_started_at = utcnow()
|
||||
started = time.monotonic()
|
||||
try:
|
||||
async with asyncio.timeout(cfg.bitrix_sync_db_check_timeout_sec):
|
||||
await app.state.probe.check()
|
||||
except Exception as exc:
|
||||
snapshot.success = False
|
||||
snapshot.error_code = classify_error(exc)
|
||||
snapshot.consecutive_failures += 1
|
||||
snapshot.state = "degraded"
|
||||
else:
|
||||
snapshot.success = True
|
||||
snapshot.error_code = None
|
||||
snapshot.consecutive_failures = 0
|
||||
snapshot.last_success_at = utcnow()
|
||||
snapshot.state = "healthy"
|
||||
finally:
|
||||
snapshot.last_finished_at = utcnow()
|
||||
snapshot.duration_ms = round((time.monotonic() - started) * 1000)
|
||||
|
||||
|
||||
async def periodic_loop(app: FastAPI) -> None:
|
||||
cfg: Settings = app.state.settings
|
||||
snapshot: Snapshot = app.state.snapshot
|
||||
try:
|
||||
while not app.state.stop.is_set():
|
||||
if snapshot.consecutive_failures:
|
||||
cap = min(
|
||||
cfg.bitrix_sync_db_retry_base_sec * 2 ** (snapshot.consecutive_failures - 1),
|
||||
cfg.bitrix_sync_db_retry_max_sec,
|
||||
)
|
||||
delay = random.uniform(0, cap)
|
||||
else:
|
||||
jitter = (
|
||||
cfg.bitrix_sync_db_check_interval_sec * cfg.bitrix_sync_db_check_jitter_ratio
|
||||
)
|
||||
delay = cfg.bitrix_sync_db_check_interval_sec + random.uniform(-jitter, jitter)
|
||||
snapshot.next_check_at = utcnow() + timedelta(seconds=delay)
|
||||
try:
|
||||
await asyncio.wait_for(app.state.stop.wait(), timeout=delay)
|
||||
break
|
||||
except TimeoutError:
|
||||
await run_probe(app)
|
||||
finally:
|
||||
snapshot.worker_running = False
|
||||
|
||||
|
||||
_settings = Settings()
|
||||
app = create_app(_settings)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8080)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
for name in ${HAN_SECRET_VARS:-}; do
|
||||
case "$name" in
|
||||
""|[0-9]*|*[!A-Z0-9_]*)
|
||||
echo "container secrets: invalid variable name" >&2
|
||||
exit 64
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
eval "file=\${${name}_FILE:-}"
|
||||
if [ -z "$file" ] || [ ! -r "$file" ]; then
|
||||
echo "container secrets: missing file for $name" >&2
|
||||
exit 66
|
||||
fi
|
||||
value=$(cat "$file")
|
||||
export "$name=$value"
|
||||
unset "${name}_FILE"
|
||||
done
|
||||
|
||||
unset HAN_SECRET_VARS
|
||||
exec "$@"
|
||||
@@ -0,0 +1,46 @@
|
||||
openapi: 3.1.0
|
||||
info: {title: HAN Bitrix Sync Connectivity Stub, version: 1.0.0}
|
||||
paths:
|
||||
/health/live:
|
||||
get:
|
||||
responses:
|
||||
"200":
|
||||
description: Process is live
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/Live"}}}
|
||||
/health/ready:
|
||||
get:
|
||||
responses:
|
||||
"200": {description: Latest PostgreSQL probe is fresh and successful}
|
||||
"503": {description: Disabled, stale, or database unavailable}
|
||||
/internal/sync/v1/status:
|
||||
get:
|
||||
security: [{BearerAuth: []}]
|
||||
parameters:
|
||||
- {name: X-Request-ID, in: header, required: false, schema: {type: string}}
|
||||
responses:
|
||||
"200":
|
||||
description: Connectivity-loop status
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/Status"}}}
|
||||
"401": {description: Service authentication failed}
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth: {type: http, scheme: bearer}
|
||||
schemas:
|
||||
Live:
|
||||
type: object
|
||||
required: [status]
|
||||
properties: {status: {const: live}}
|
||||
Status:
|
||||
type: object
|
||||
required: [service, enabled, mode, crm_sync_implemented, state, started_at]
|
||||
properties:
|
||||
service: {const: bitrix-sync}
|
||||
enabled: {type: boolean}
|
||||
mode: {const: db_connectivity_stub}
|
||||
crm_sync_implemented: {const: false}
|
||||
state: {type: string, enum: [starting, disabled, healthy, degraded, stopping]}
|
||||
started_at: {type: string, format: date-time}
|
||||
last_check: {type: [object, "null"]}
|
||||
last_success_at: {type: [string, "null"], format: date-time}
|
||||
consecutive_failures: {type: integer, minimum: 0}
|
||||
next_check_in_seconds: {type: [number, "null"]}
|
||||
@@ -0,0 +1,30 @@
|
||||
[project]
|
||||
name = "han-bitrix-sync"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"alembic>=1.16,<2",
|
||||
"asyncpg>=0.30,<1",
|
||||
"fastapi>=0.116,<1",
|
||||
"pydantic-settings>=2.10,<3",
|
||||
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||
"uvicorn[standard]>=0.35,<1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["httpx>=0.28,<1", "pytest>=8.4,<9", "pytest-asyncio>=1,<2", "ruff>=0.12,<1"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
@@ -0,0 +1,65 @@
|
||||
import os
|
||||
|
||||
os.environ.setdefault("BITRIX_SYNC_ENABLED", "false")
|
||||
os.environ.setdefault("BITRIX_SYNC_SERVICE_TOKEN", "test-sync-token-32-characters")
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.main import Settings, create_app
|
||||
|
||||
|
||||
class Probe:
|
||||
def __init__(self, fail=False):
|
||||
self.fail = fail
|
||||
self.calls = 0
|
||||
|
||||
async def check(self):
|
||||
self.calls += 1
|
||||
if self.fail:
|
||||
raise OSError("down")
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_semantics():
|
||||
settings = Settings(
|
||||
bitrix_sync_enabled=False,
|
||||
bitrix_sync_service_token="test-sync-token-32-characters",
|
||||
)
|
||||
app = create_app(settings)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
assert (await client.get("/health/live")).status_code == 200
|
||||
ready = await client.get("/health/ready")
|
||||
assert ready.status_code == 503
|
||||
assert ready.json()["reason"] == "sync_disabled"
|
||||
status = await client.get(
|
||||
"/internal/sync/v1/status",
|
||||
headers={"Authorization": "Bearer test-sync-token-32-characters"},
|
||||
)
|
||||
assert status.json()["state"] == "disabled"
|
||||
assert status.json()["crm_sync_implemented"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initial_probe_and_auth():
|
||||
probe = Probe()
|
||||
settings = Settings(
|
||||
bitrix_sync_enabled=True,
|
||||
bitrix_sync_database_url="postgresql://unused/unused",
|
||||
bitrix_sync_service_token="test-sync-token-32-characters",
|
||||
bitrix_sync_db_check_interval_sec=60,
|
||||
)
|
||||
app = create_app(settings, probe)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
assert probe.calls == 1
|
||||
assert (await client.get("/health/ready")).status_code == 200
|
||||
assert (await client.get("/internal/sync/v1/status")).status_code == 401
|
||||
@@ -0,0 +1,826 @@
|
||||
# Подробная инструкция по развертыванию и запуску HAN Chat
|
||||
|
||||
Эта инструкция описывает первый запуск **текущего legacy/stub проекта ВМ1** на одной виртуальной
|
||||
машине с Ubuntu 24.04. Она не разворачивает target ВМ2 Processing и не подтверждает production-готовность Message Safety v2. Все команды предполагают, что проект расположен в
|
||||
`/opt/han-chat/backend`, а команды Docker Compose выполняются из этого каталога.
|
||||
|
||||
PostgreSQL и Selectel S3 не запускаются в Docker Compose: их необходимо создать
|
||||
заранее как внешние управляемые сервисы. Из интернета должны быть доступны только
|
||||
порты 80 и 443 виртуальной машины.
|
||||
|
||||
## 1. Что потребуется до начала работы
|
||||
|
||||
Подготовьте:
|
||||
|
||||
1. Виртуальную машину с Ubuntu 24.04 и минимум 4 vCPU, 8 ГБ RAM и 40 ГБ диска.
|
||||
2. SSH-доступ к VM пользователем с правом `sudo`.
|
||||
3. Домен, например `chat.example.ru`, и возможность изменить его DNS.
|
||||
4. Управляемый PostgreSQL, доступный VM по приватной сети.
|
||||
5. Три приватных бакета Selectel S3.
|
||||
6. Учетные данные приложения Bitrix24.
|
||||
7. При необходимости — удаленный OTLP-бэкенд для телеметрии.
|
||||
8. Локальную копию каталога `HAN_chat_specification/codebase/backend` либо URL
|
||||
Git-репозитория, из которого его можно получить.
|
||||
|
||||
Для первого тестового запуска допустимы mock OTP, заглушка Message Safety и
|
||||
заглушка bitrix-sync. Они не являются полноценными production-реализациями.
|
||||
|
||||
Целевой cutover выполняется по `modules/module-10-deployment-runbook.md`: самостоятельная ВМ2, root Compose/systemd unit, собственный nginx с public exact CRM webhook `80/443` и private Message Safety listener `8443`, раздельные TLS-контуры, secrets/IAM, egress allow-list и local OTEL Collector. Не переносите команды этого single-VM guide на ВМ2 без VM2-specific manifests.
|
||||
|
||||
## 2. Первичный вход на VM
|
||||
|
||||
Подключитесь к созданной VM облачным пользователем:
|
||||
|
||||
```sh
|
||||
ssh <cloud-user>@<VM_IP>
|
||||
```
|
||||
|
||||
Проверьте версию ОС:
|
||||
|
||||
```sh
|
||||
cat /etc/os-release
|
||||
```
|
||||
|
||||
Должна использоваться Ubuntu 24.04 или более новая версия.
|
||||
|
||||
## 3. Передача и запуск скрипта настройки VM
|
||||
|
||||
Сначала передайте на VM только подготовительный скрипт. Например, с локального
|
||||
компьютера:
|
||||
|
||||
```sh
|
||||
scp deployment/scripts/setup-vm.sh <cloud-user>@<VM_IP>:/tmp/setup-vm.sh
|
||||
```
|
||||
|
||||
На VM выполните:
|
||||
|
||||
```sh
|
||||
chmod +x /tmp/setup-vm.sh
|
||||
sudo /tmp/setup-vm.sh
|
||||
```
|
||||
|
||||
Скрипт:
|
||||
|
||||
- обновит Ubuntu и установит базовые пакеты;
|
||||
- создаст пользователя `deploy`;
|
||||
- установит Docker Engine и Docker Compose;
|
||||
- настроит UFW, fail2ban и цепочку `DOCKER-USER`;
|
||||
- откроет только SSH, HTTP и HTTPS;
|
||||
- создаст `/opt/han-chat/backend`;
|
||||
- создаст swap;
|
||||
- включит автоматические обновления безопасности;
|
||||
- отключит парольный SSH-вход и X11 forwarding;
|
||||
- заблокирует локальные пароли `root` и `deploy` после проверки SSH-ключей.
|
||||
|
||||
Если `authorized_keys` пользователя `deploy` отсутствует, скрипт остановится до
|
||||
блокировки паролей. `HARDEN_SSH=true` дополнительно запрещает прямой вход
|
||||
пользователем `root` и SSH TCP forwarding; включайте этот режим только после
|
||||
проверки входа пользователем `deploy` по ключу в отдельной сессии.
|
||||
|
||||
Если SSH работает на нестандартном порту или имя внешнего интерфейса известно
|
||||
заранее, передайте параметры:
|
||||
|
||||
```sh
|
||||
sudo SSH_PORT=2222 EXTERNAL_IF=ens3 /tmp/setup-vm.sh
|
||||
```
|
||||
|
||||
После завершения выйдите из SSH-сессии: членство `deploy` в группе `docker`
|
||||
начинает действовать только после нового входа.
|
||||
|
||||
```sh
|
||||
exit
|
||||
ssh deploy@<VM_IP>
|
||||
docker version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
## 4. Копирование проекта на VM
|
||||
|
||||
### Вариант A — через Git
|
||||
|
||||
Это предпочтительный вариант: Git применит правило LF для shell-скриптов.
|
||||
|
||||
```sh
|
||||
git clone <URL_РЕПОЗИТОРИЯ> /tmp/han-chat-source
|
||||
cp -a /tmp/han-chat-source/HAN_chat_specification/codebase/backend/. \
|
||||
/opt/han-chat/backend/
|
||||
cd /opt/han-chat/backend
|
||||
```
|
||||
|
||||
Если `HAN_chat_specification` является корнем репозитория:
|
||||
|
||||
```sh
|
||||
cp -a /tmp/han-chat-source/codebase/backend/. /opt/han-chat/backend/
|
||||
```
|
||||
|
||||
### Вариант B — архивом с локального компьютера
|
||||
|
||||
Создайте архив именно из содержимого каталога `backend`, включая скрытые файлы:
|
||||
|
||||
```sh
|
||||
tar -C HAN_chat_specification/codebase/backend -czf han-chat-backend.tar.gz .
|
||||
scp han-chat-backend.tar.gz deploy@<VM_IP>:/tmp/
|
||||
```
|
||||
|
||||
На VM:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
tar -xzf /tmp/han-chat-backend.tar.gz
|
||||
|
||||
# Обязательно при копировании с Windows:
|
||||
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
|
||||
```
|
||||
|
||||
Проверьте наличие точки запуска:
|
||||
|
||||
```sh
|
||||
test -f /opt/han-chat/backend/docker-compose.yml
|
||||
test -f /opt/han-chat/backend/.env.example
|
||||
```
|
||||
|
||||
## 5. Настройка DNS и сетевого доступа
|
||||
|
||||
Создайте DNS-запись:
|
||||
|
||||
```text
|
||||
chat.example.ru A <ПУБЛИЧНЫЙ_IP_VM>
|
||||
```
|
||||
|
||||
Дождитесь обновления DNS:
|
||||
|
||||
```sh
|
||||
getent ahostsv4 chat.example.ru
|
||||
```
|
||||
|
||||
В облачной группе безопасности VM разрешите входящие подключения:
|
||||
|
||||
- TCP 80 из интернета;
|
||||
- TCP 443 из интернета;
|
||||
- SSH только из доверенной сети или с административного IP.
|
||||
|
||||
Не открывайте наружу порты 6379, 4317, 4318, 8000, 8080 и 9000.
|
||||
|
||||
В группе безопасности PostgreSQL разрешите входящий трафик на порт PostgreSQL
|
||||
только от приватного адреса или группы безопасности VM.
|
||||
|
||||
## 6. Подготовка управляемого PostgreSQL
|
||||
|
||||
Создайте одну базу данных:
|
||||
|
||||
```text
|
||||
han_chat
|
||||
```
|
||||
|
||||
В ней нужны схемы:
|
||||
|
||||
```text
|
||||
han_app
|
||||
bitrix_local
|
||||
bitrix_sync
|
||||
message_safety
|
||||
keycloak
|
||||
```
|
||||
|
||||
Для текущей MVP-реализации используются следующие пользователи:
|
||||
|
||||
```text
|
||||
han_app
|
||||
bitrix_local_app
|
||||
bitrix_sync_user
|
||||
message_safety_app
|
||||
keycloak_user
|
||||
```
|
||||
|
||||
Создать пользователей и схемы можно через панель провайдера либо от имени
|
||||
администратора PostgreSQL. Пример SQL:
|
||||
|
||||
```sql
|
||||
CREATE ROLE han_app LOGIN PASSWORD '<HAN_APP_PASSWORD>';
|
||||
CREATE ROLE bitrix_local_app LOGIN PASSWORD '<BITRIX_LOCAL_PASSWORD>';
|
||||
CREATE ROLE bitrix_sync_user LOGIN PASSWORD '<BITRIX_SYNC_PASSWORD>';
|
||||
CREATE ROLE message_safety_app LOGIN PASSWORD '<SAFETY_PASSWORD>';
|
||||
CREATE ROLE keycloak_user LOGIN PASSWORD '<KEYCLOAK_PASSWORD>';
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS han_app AUTHORIZATION han_app;
|
||||
CREATE SCHEMA IF NOT EXISTS bitrix_local AUTHORIZATION bitrix_local_app;
|
||||
CREATE SCHEMA IF NOT EXISTS bitrix_sync AUTHORIZATION bitrix_sync_user;
|
||||
CREATE SCHEMA IF NOT EXISTS message_safety AUTHORIZATION message_safety_app;
|
||||
CREATE SCHEMA IF NOT EXISTS keycloak AUTHORIZATION keycloak_user;
|
||||
|
||||
GRANT CONNECT ON DATABASE han_chat TO
|
||||
han_app, bitrix_local_app, bitrix_sync_user, message_safety_app, keycloak_user;
|
||||
```
|
||||
|
||||
Текущие migration jobs используют те же DSN, что и сервисы. Поэтому владельцы
|
||||
схем должны иметь право создавать таблицы в своих схемах. Для более строгого
|
||||
production-разделения migration/runtime ролей потребуется отдельная настройка
|
||||
DSN и прав, которой в текущем `.env.example` нет.
|
||||
|
||||
Скачайте CA-сертификат PostgreSQL у провайдера и поместите его на VM:
|
||||
|
||||
```sh
|
||||
mkdir -p /opt/han-chat/backend/secrets/pg
|
||||
cp /путь/к/ca.pem /opt/han-chat/backend/secrets/pg/ca.pem
|
||||
chmod 644 /opt/han-chat/backend/secrets/pg/ca.pem
|
||||
```
|
||||
|
||||
CA-сертификат не является секретом. Права `644` нужны, чтобы его могли прочитать
|
||||
контейнеры, работающие не от root.
|
||||
|
||||
Проверьте сетевую доступность:
|
||||
|
||||
```sh
|
||||
nc -vz <PG_HOST> 6432
|
||||
```
|
||||
|
||||
Замените `6432` на фактический порт провайдера.
|
||||
|
||||
## 7. Подготовка Selectel S3
|
||||
|
||||
Создайте три приватных бакета:
|
||||
|
||||
```text
|
||||
han-chat-quarantine
|
||||
han-chat-attachments
|
||||
han-chat-documents
|
||||
```
|
||||
|
||||
Создайте две пары ключей:
|
||||
|
||||
1. Ключ API с правом чтения и записи в бакеты.
|
||||
2. Отдельный ключ Message Safety только с правом чтения карантина.
|
||||
|
||||
Для бакетов запретите публичный доступ. Для браузерной загрузки настройте CORS:
|
||||
|
||||
- Allowed origin: `https://chat.example.ru`;
|
||||
- Methods: `PUT`, `GET`, `HEAD`;
|
||||
- Headers: `Content-Type`, `x-amz-*`;
|
||||
- Expose header: `ETag`.
|
||||
|
||||
Для карантина задайте lifecycle удаления объектов с запасом относительно
|
||||
`MESSAGE_SAFETY_TASK_TTL_SEC`.
|
||||
|
||||
## 8. Создание файла окружения
|
||||
|
||||
`.env` содержит только несекретную конфигурацию. На VM:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
umask 077
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
Замените несекретные адреса `example.*`. Не добавляйте в `.env` пароли, токены,
|
||||
ключи, credential-bearing DSN или пути `*_FILE`. Установите отдельно проверенный
|
||||
launcher `deployment/secrets/han-secrets` из ops-пакета. Его интерфейс:
|
||||
|
||||
```sh
|
||||
deployment/secrets/han-secrets run --config .env -- <command>
|
||||
```
|
||||
|
||||
Launcher читает `SECRETS_SOURCE=file|selectel`, устанавливает
|
||||
`HAN_SECRETS_ACTIVE=1`, выдаёт значения только дочернему процессу и не печатает
|
||||
их. Рекомендуемый `HAN_RUNTIME_SECRET_MANIFEST` содержит только пары
|
||||
`SECRET_KEY=/absolute/protected/path`; файлы имеют mode `0400`/`0600`.
|
||||
|
||||
### 8.1. Основные адреса
|
||||
|
||||
Для домена `chat.example.ru`:
|
||||
|
||||
```dotenv
|
||||
APP_ENV=production-like
|
||||
RELEASE_VERSION=2026-07-13-1
|
||||
|
||||
PUBLIC_HOST=chat.example.ru
|
||||
PUBLIC_WEB_URL=https://chat.example.ru
|
||||
PUBLIC_API_URL=https://chat.example.ru/api
|
||||
PUBLIC_AUTH_URL=https://chat.example.ru/auth
|
||||
|
||||
KEYCLOAK_PUBLIC_URL=https://chat.example.ru/auth
|
||||
KEYCLOAK_INTERNAL_URL=http://keycloak:8080/auth
|
||||
KEYCLOAK_REALM=han-chat
|
||||
KEYCLOAK_AUDIENCE=han-chat-api
|
||||
```
|
||||
|
||||
### 8.2. PostgreSQL
|
||||
|
||||
В `.env` укажите только host, port, database и путь к публичному CA:
|
||||
|
||||
```dotenv
|
||||
HAN_PG_HOST=<PG_HOST>
|
||||
HAN_PG_PORT=6432
|
||||
HAN_PG_DATABASE=han_chat
|
||||
PG_CA_HOST_PATH=/opt/han-chat/backend/secrets/pg/ca.pem
|
||||
|
||||
KEYCLOAK_DB_SCHEMA=keycloak
|
||||
```
|
||||
|
||||
Все service DSN, включая JDBC и backup DSN, формирует secret backend. Runtime
|
||||
validator проверяет `verify-full`, `sslrootcert` и запрет `options/currentSchema`
|
||||
без вывода строк подключения.
|
||||
|
||||
Порт `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.
|
||||
|
||||
### 8.3. Подготовка runtime-секретов
|
||||
|
||||
Генерируйте секреты вне shell history средствами secret manager. Не выполняйте
|
||||
`export TOKEN=...` и не вставляйте значения в команды. Имена обязательных
|
||||
runtime-переменных определены в `scripts/validate-env`; парные токены связываются
|
||||
в secret backend.
|
||||
|
||||
```sh
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Для `BITRIX_TOKEN_ENCRYPTION_KEY` нужен URL-safe Base64 ключ ровно из 32 байт:
|
||||
|
||||
```sh
|
||||
python3 -c 'import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())'
|
||||
```
|
||||
|
||||
Ни один из этих секретов не добавляется в `.env`. Пары проверяются runtime
|
||||
validator без вывода значений.
|
||||
|
||||
```dotenv
|
||||
BITRIX_LOCAL_APP_INTERNAL_TOKEN=<TOKEN_A>
|
||||
BITRIX_INTERNAL_API_TOKEN=<TOKEN_A>
|
||||
|
||||
BITRIX_API_FORWARD_TOKEN=<TOKEN_B>
|
||||
BITRIX_API_INBOX_TOKEN=<TOKEN_B>
|
||||
```
|
||||
|
||||
Остальные токены должны быть разными:
|
||||
|
||||
```dotenv
|
||||
MESSAGE_SAFETY_SERVICE_TOKEN=<UNIQUE_TOKEN>
|
||||
BITRIX_SYNC_SERVICE_TOKEN=<UNIQUE_TOKEN>
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=<UNIQUE_TOKEN>
|
||||
CURSOR_HMAC_SECRET=<UNIQUE_TOKEN>
|
||||
KEYCLOAK_OTP_HMAC_KEY=<UNIQUE_TOKEN_НЕ_КОРОЧЕ_32_БАЙТ>
|
||||
BITRIX_TOKEN_ENCRYPTION_KEY=<URLSAFE_BASE64_KEY>
|
||||
KEYCLOAK_ADMIN_PASSWORD=<UNIQUE_ADMIN_PASSWORD>
|
||||
```
|
||||
|
||||
### 8.4. Redis
|
||||
|
||||
Создайте три разных пароля в secret backend; там же сформируйте Redis URL.
|
||||
Следующий блок описывает логический контракт и не является содержимым `.env`:
|
||||
|
||||
```dotenv
|
||||
REDIS_API_PASSWORD=<REDIS_API_PASSWORD>
|
||||
REDIS_SAFETY_PASSWORD=<REDIS_SAFETY_PASSWORD>
|
||||
REDIS_HEALTH_PASSWORD=<REDIS_HEALTH_PASSWORD>
|
||||
|
||||
REDIS_URL=redis://api_backend:<REDIS_API_PASSWORD>@redis:6379/0
|
||||
REDIS_REALTIME_URL=redis://api_backend:<REDIS_API_PASSWORD>@redis:6379/1
|
||||
MESSAGE_SAFETY_REDIS_URL=redis://message_safety:<REDIS_SAFETY_PASSWORD>@redis:6379/2
|
||||
```
|
||||
|
||||
### 8.5. Mock OTP
|
||||
|
||||
В текущих deploy-артефактах реализован только mock OTP. Для запуска до controlled SMS rollout:
|
||||
|
||||
```dotenv
|
||||
KEYCLOAK_OTP_MOCK_ENABLED=true
|
||||
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true
|
||||
```
|
||||
|
||||
`KEYCLOAK_OTP_MOCK_CODE` хранится только в secret backend. Не используйте mock
|
||||
как production-механизм доставки OTP.
|
||||
|
||||
Целевой real mode задаёт `modules/module-11-idgtl-sms.md`: Keycloak генерирует и локально проверяет OTP, `sms-service` надёжно записывает заказ/журнал, worker вызывает i-Digital Direct, callback обновляет только delivery journal. Нельзя просто установить `KEYCLOAK_OTP_MOCK_ENABLED=false`.
|
||||
|
||||
До переключения необходимы: schema/role `sms` и migrations/seed, active approved `auth_otp` (`code`, `ttl_min`), согласованный sender, Direct `TOKEN_1`, парные service tokens, отдельные callback credentials, exact nginx callback route, подтверждённый source IP Direct и статический egress IP worker. Сначала deploy при mock=true, затем provider smoke/callback/redaction evidence и только после этого cutover. Rollback возвращает mock без удаления SMS schema/journal.
|
||||
|
||||
### 8.6. S3
|
||||
|
||||
```dotenv
|
||||
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
|
||||
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
|
||||
SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments
|
||||
SELECTEL_S3_BUCKET_DOCUMENTS=han-chat-documents
|
||||
```
|
||||
|
||||
Обе пары S3 credentials хранятся только в secret backend.
|
||||
|
||||
API backend принудительно использует virtual-hosted addressing:
|
||||
`https://<bucket>.s3.storage.selcloud.ru/<object-key>`. Это обязательно для
|
||||
браузерных presigned PUT и CORS в Selectel; path-style URL для этого сценария не
|
||||
используйте. DNS и исходящий HTTPS с ВМ должны разрешать поддомены бакетов.
|
||||
|
||||
### 8.7. Bitrix24
|
||||
|
||||
До установки локального приложения загрузите credentials в secret backend.
|
||||
В `.env` остаются только несекретные connector/public URL параметры:
|
||||
|
||||
```dotenv
|
||||
BITRIX_CONNECTOR_ID=han_mobile_app
|
||||
BITRIX_OPEN_LINE_ID=8
|
||||
BITRIX_PUBLIC_BASE_URL=https://chat.example.ru/bitrix
|
||||
```
|
||||
|
||||
`BITRIX_APPLICATION_TOKEN` сохраняется в secret backend после создания
|
||||
приложения и никогда не помещается в `.env`.
|
||||
|
||||
### 8.8. TLS и наблюдаемость
|
||||
|
||||
До выпуска сертификата оставьте в `.env` целевые значения:
|
||||
|
||||
```dotenv
|
||||
NGINX_TLS_ENABLED=true
|
||||
NGINX_TLS_CERTIFICATE=/etc/letsencrypt/live/chat.example.ru/fullchain.pem
|
||||
NGINX_TLS_CERTIFICATE_KEY=/etc/letsencrypt/live/chat.example.ru/privkey.pem
|
||||
ACME_EMAIL=<ADMIN_EMAIL>
|
||||
```
|
||||
|
||||
Если удаленный OTLP-бэкенд пока не выбран, укажите временные непубличные значения
|
||||
и примите ограничение: Collector будет пытаться отправлять телеметрию и сохранять
|
||||
ее в ограниченной очереди. Перед production-запуском задайте реальный endpoint.
|
||||
|
||||
## 9. Проверка окружения и конфигурации Compose
|
||||
|
||||
Выполните:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
./scripts/validate-env .env
|
||||
sudo systemctl restart han-secrets@production.service
|
||||
sudo ./scripts/validate-env .env \
|
||||
--runtime-manifest /run/han-chat/secrets/manifest
|
||||
sudo deployment/secrets/han-compose config --quiet
|
||||
sudo deployment/secrets/han-compose config --services
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
Не переходите к следующему шагу, пока все команды не завершатся успешно.
|
||||
|
||||
Посмотрите итоговую конфигурацию портов:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env config | grep -n 'published:'
|
||||
```
|
||||
|
||||
Публиковаться должны только 80 и 443 у nginx.
|
||||
|
||||
## 10. Сборка образов
|
||||
|
||||
Соберите все локальные образы:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
docker compose --env-file .env build --pull
|
||||
```
|
||||
|
||||
Проверьте список:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env images
|
||||
```
|
||||
|
||||
Сборка Keycloak включает Java OTP SPI, а сборка `frontend-static` экспортирует
|
||||
тестовый Expo Web frontend.
|
||||
|
||||
## 11. Миграции БД и начальные настройки
|
||||
|
||||
Перед миграциями создайте backup/PITR marker в панели провайдера PostgreSQL.
|
||||
Затем:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
|
||||
deployment/scripts/seed.sh
|
||||
```
|
||||
|
||||
Скрипт применит миграции `han_app`, `bitrix_local` и baseline `bitrix_sync`, после
|
||||
чего загрузит `deployment/app-settings.production-like.yaml`.
|
||||
|
||||
Повторный запуск seed должен быть безопасным:
|
||||
|
||||
```sh
|
||||
deployment/scripts/seed.sh
|
||||
```
|
||||
|
||||
## 12. Запуск внутренних сервисов
|
||||
|
||||
Сначала запустите 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>)"
|
||||
```
|
||||
|
||||
### 12.1. Повторная раскатка upstream при уже работающем nginx
|
||||
|
||||
Nginx разрешает Docker DNS имена upstream при загрузке конфигурации. После
|
||||
`up --build`, `pull`, rollback или `--force-recreate` контейнер может получить
|
||||
новый IP, а работающий nginx продолжит использовать старый и вернёт `502
|
||||
Connection refused`.
|
||||
|
||||
После пересоздания `api-backend`, `keycloak`, `sms-service` или
|
||||
`bitrix-local-app` обязательно выполните:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env up -d --wait \
|
||||
api-backend keycloak sms-service bitrix-local-app
|
||||
docker compose --env-file .env exec -T nginx \
|
||||
nginx -t -c /tmp/nginx.conf
|
||||
docker compose --env-file .env kill -s HUP nginx
|
||||
|
||||
curl -fsS "https://${PUBLIC_HOST}/api/v1/public/app-config" | jq
|
||||
curl -fsS \
|
||||
"https://${PUBLIC_HOST}/auth/realms/han-chat/.well-known/openid-configuration" |
|
||||
jq
|
||||
```
|
||||
|
||||
Не используйте bare-команды `nginx -t` и `nginx -s reload`: рабочая
|
||||
конфигурация находится в `/tmp/nginx.conf`, PID — в `/tmp/nginx.pid`, а
|
||||
контейнер использует read-only filesystem.
|
||||
|
||||
## 13. Первоначальный выпуск TLS-сертификата
|
||||
|
||||
Для ACME требуется работающий nginx по HTTP. В `.env` оставьте
|
||||
`NGINX_TLS_ENABLED=true`, но первый nginx запустите с временным переопределением:
|
||||
|
||||
```sh
|
||||
NGINX_TLS_ENABLED=false \
|
||||
docker compose --env-file .env up -d frontend-static nginx
|
||||
```
|
||||
|
||||
Проверьте HTTP:
|
||||
|
||||
```sh
|
||||
curl -I http://chat.example.ru/
|
||||
```
|
||||
|
||||
Сначала рекомендуется проверить Certbot через staging:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env --profile certbot run --rm certbot certonly \
|
||||
--staging \
|
||||
--webroot -w /var/www/certbot \
|
||||
-d chat.example.ru \
|
||||
--cert-name chat.example.ru-staging \
|
||||
--email <ADMIN_EMAIL> \
|
||||
--agree-tos --no-eff-email --non-interactive
|
||||
```
|
||||
|
||||
После успешного staging-теста выпустите рабочий сертификат с основным cert-name
|
||||
без `--staging`:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env --profile certbot run --rm certbot certonly \
|
||||
--webroot -w /var/www/certbot \
|
||||
-d chat.example.ru \
|
||||
--cert-name chat.example.ru \
|
||||
--email <ADMIN_EMAIL> \
|
||||
--agree-tos --no-eff-email --non-interactive
|
||||
```
|
||||
|
||||
Пересоздайте nginx уже с TLS:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env up -d --force-recreate nginx
|
||||
docker compose --env-file .env exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
curl -I https://chat.example.ru/
|
||||
```
|
||||
|
||||
Повторно запустите VM setup, чтобы он обнаружил проект и установил systemd-таймер
|
||||
продления сертификата:
|
||||
|
||||
```sh
|
||||
sudo /opt/han-chat/backend/deployment/scripts/setup-vm.sh
|
||||
systemctl status han-chat-ssl-renew.timer
|
||||
```
|
||||
|
||||
## 14. Запуск всего контура
|
||||
|
||||
Теперь можно привести весь проект к состоянию, описанному Compose:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
docker compose --env-file .env up -d
|
||||
docker compose --env-file .env ps
|
||||
```
|
||||
|
||||
Проверьте, что контейнеры не перезапускаются:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env ps
|
||||
docker compose --env-file .env logs --since=10m
|
||||
```
|
||||
|
||||
## 15. Публичная проверка
|
||||
|
||||
Запустите smoke-тест:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
deployment/scripts/smoke.sh
|
||||
```
|
||||
|
||||
Также вручную проверьте:
|
||||
|
||||
```sh
|
||||
curl -fsS https://chat.example.ru/api/v1/public/app-config | jq
|
||||
curl -fsS https://chat.example.ru/api/v1/public/content | jq
|
||||
curl -fsS \
|
||||
https://chat.example.ru/auth/realms/han-chat/.well-known/openid-configuration | jq
|
||||
```
|
||||
|
||||
Внутренний API не должен быть опубликован:
|
||||
|
||||
```sh
|
||||
curl -i https://chat.example.ru/internal/safety/v2/messages/check
|
||||
```
|
||||
|
||||
Ожидаемый статус — `404`.
|
||||
|
||||
Откройте в браузере:
|
||||
|
||||
```text
|
||||
https://chat.example.ru/
|
||||
```
|
||||
|
||||
Для тестовой авторизации получите mock code утверждённым защищённым способом,
|
||||
не читая его из `.env` и не помещая в shell history.
|
||||
|
||||
## 16. Подключение Bitrix24
|
||||
|
||||
В настройках локального приложения Bitrix24 задайте HTTPS-адреса:
|
||||
|
||||
```text
|
||||
Установка: https://chat.example.ru/bitrix/install
|
||||
Обработчик: https://chat.example.ru/bitrix/handler
|
||||
Placement: https://chat.example.ru/bitrix/placement
|
||||
```
|
||||
|
||||
После установки:
|
||||
|
||||
1. Получите и сохраните application token.
|
||||
2. Сохраните его как `BITRIX_APPLICATION_TOKEN` в secret backend.
|
||||
3. Пересоздайте сервис:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env up -d --force-recreate bitrix-local-app
|
||||
docker compose --env-file .env logs --tail=200 bitrix-local-app
|
||||
```
|
||||
|
||||
Проверьте коннектор `han_mobile_app` и Открытую линию 8.
|
||||
|
||||
## 17. Включение SSH hardening
|
||||
|
||||
Только после успешного входа пользователем `deploy` по ключу в отдельной сессии:
|
||||
|
||||
```sh
|
||||
sudo HARDEN_SSH=true \
|
||||
/opt/han-chat/backend/deployment/scripts/setup-vm.sh
|
||||
```
|
||||
|
||||
Не закрывайте текущую SSH-сессию, пока не проверили новый вход.
|
||||
|
||||
## 18. Обычный перезапуск проекта
|
||||
|
||||
Для штатного запуска после перезагрузки VM:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
docker compose --env-file .env up -d
|
||||
docker compose --env-file .env ps
|
||||
```
|
||||
|
||||
Для перезапуска одного сервиса:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env restart api-backend
|
||||
```
|
||||
|
||||
После изменения `.env` используйте пересоздание, а не `restart`:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env up -d --force-recreate <SERVICE_NAME>
|
||||
```
|
||||
|
||||
## 19. Обновление версии проекта
|
||||
|
||||
Перед обновлением:
|
||||
|
||||
1. Создайте backup/PITR marker PostgreSQL.
|
||||
2. Сохраните текущие image digests.
|
||||
3. Получите новый код.
|
||||
4. Проверьте несекретный `.env` и runtime secret set.
|
||||
5. Пересоберите образы.
|
||||
6. Примените миграции и seed.
|
||||
7. Пересоздайте сервисы.
|
||||
|
||||
Команды:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
./scripts/validate-env .env
|
||||
docker compose --env-file .env build --pull
|
||||
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
|
||||
deployment/scripts/seed.sh
|
||||
docker compose --env-file .env up -d
|
||||
deployment/scripts/smoke.sh
|
||||
```
|
||||
|
||||
## 20. Диагностика
|
||||
|
||||
Состояние сервисов:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env ps
|
||||
```
|
||||
|
||||
Все логи:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env logs --tail=300
|
||||
```
|
||||
|
||||
Логи конкретного сервиса:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env logs -f api-backend
|
||||
```
|
||||
|
||||
Проверка firewall:
|
||||
|
||||
```sh
|
||||
sudo ufw status verbose
|
||||
sudo iptables -L HAN-CHAT-DOCKER -n -v
|
||||
```
|
||||
|
||||
Проверка сертификата:
|
||||
|
||||
```sh
|
||||
openssl s_client -connect chat.example.ru:443 -servername chat.example.ru \
|
||||
</dev/null 2>/dev/null | openssl x509 -noout -subject -issuer -dates
|
||||
```
|
||||
|
||||
Проверка свободного места:
|
||||
|
||||
```sh
|
||||
df -h
|
||||
docker system df
|
||||
```
|
||||
|
||||
Не выполняйте `docker compose down -v`: эта команда удалит именованные volumes.
|
||||
Не выполняйте Alembic downgrade. Для отката используйте
|
||||
`deployment/scripts/rollback.sh` и инструкции из `RUNBOOK.ru.md`.
|
||||
|
||||
## 21. Когда развертывание можно считать завершенным
|
||||
|
||||
Проект запущен корректно, если:
|
||||
|
||||
- `docker compose ps` показывает healthy для критических сервисов;
|
||||
- `deployment/scripts/smoke.sh` завершается успешно;
|
||||
- открывается тестовый frontend;
|
||||
- проходит авторизация через mock OTP;
|
||||
- отправляются текстовые и файловые сообщения;
|
||||
- внутренние URL возвращают 404 снаружи;
|
||||
- TLS-сертификат действителен;
|
||||
- логи не содержат токены, PII и тексты сообщений;
|
||||
- настроены резервное копирование PostgreSQL и продление TLS.
|
||||
|
||||
Для формальной production-like приемки после этого пройдите контрольные этапы
|
||||
из `deployment/RUNBOOK.ru.md`.
|
||||
@@ -0,0 +1,330 @@
|
||||
# HAN Chat production-like deployment runbook
|
||||
|
||||
This is the executable checklist for the single-VM contour. PostgreSQL and S3
|
||||
are managed external services. Never use `docker compose down -v`, an Alembic
|
||||
downgrade, or a mutable image tag during deployment.
|
||||
|
||||
## VM2 Processing is a separate host
|
||||
|
||||
Do not run this backend/VM1 setup script on VM2. VM2 has its own bootstrap:
|
||||
`codebase/services/deployment/scripts/setup-vm.sh`, and its authoritative
|
||||
operator checklist is `codebase/services/deployment/RUNBOOK.ru.md`.
|
||||
|
||||
The VM2 ownership boundary is intentionally different from the legacy VM1
|
||||
script: `deploy` is **not** a member of the `docker` group. Root owns
|
||||
`/opt/han-chat/services`, Compose, units, helpers, `.env`, allow-lists and
|
||||
secret mappings. Deploy may write only to `/var/lib/han-deploy/incoming` and
|
||||
may invoke exact systemd/safety-mode commands installed in sudoers.
|
||||
The separate `admin` account is break-glass only: it has its own Ed25519 key
|
||||
and a separate local sudo password. Root, deploy and admin keys must differ.
|
||||
|
||||
Initial VM2 bootstrap commands:
|
||||
|
||||
```sh
|
||||
# Local operator workstation: upload only the reviewed setup script.
|
||||
scp codebase/services/deployment/scripts/setup-vm.sh \
|
||||
root@<VM2_PUBLIC_IP>:/root/setup-vm2.sh
|
||||
|
||||
# VM2 root: install host packages/roles/firewalls; this does not start Compose.
|
||||
chmod 0700 /root/setup-vm2.sh
|
||||
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
|
||||
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
|
||||
OPS_CIDRS='<OPS_PUBLIC_IP>/32' \
|
||||
VM1_PRIVATE_CIDRS='<VM1_PRIVATE_IP>/32' \
|
||||
/root/setup-vm2.sh
|
||||
```
|
||||
|
||||
Generate and upload the two public keys before this command; never copy the
|
||||
root key into either account. Set the admin sudo password with `passwd admin`.
|
||||
Keep the root session open and verify both key-based logins plus `sudo -v` as
|
||||
admin in separate sessions. Only then rerun as VM2 root with
|
||||
`HARDEN_SSH=true SKIP_APT_UPGRADE=true` to disable direct root SSH.
|
||||
|
||||
Release transfer is performed as deploy, while activation and installation
|
||||
remain root operations:
|
||||
|
||||
```sh
|
||||
# deploy: receive and inspect only.
|
||||
cd /var/lib/han-deploy/incoming
|
||||
sha256sum vm2-services-<RELEASE>.tar.gz
|
||||
tar -tzf vm2-services-<RELEASE>.tar.gz
|
||||
|
||||
# root: verify the operator-provided digest, activate root-owned files,
|
||||
# then rerun setup-vm.sh so it installs fixed helpers and systemd units.
|
||||
printf '%s %s\n' '<EXPECTED_SHA256>' \
|
||||
/var/lib/han-deploy/incoming/vm2-services-<RELEASE>.tar.gz | sha256sum --check -
|
||||
ARCHIVE=/var/lib/han-deploy/incoming/vm2-services-<RELEASE>.tar.gz
|
||||
if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^services/\.env$)'; then exit 1; fi
|
||||
if tar -tzf "$ARCHIVE" | grep -Ev '^services(/|$)' | grep -q .; then exit 1; fi
|
||||
if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ {found=1} END {exit !found}'; then exit 1; fi
|
||||
STAGING="$(mktemp -d /opt/han-chat/.vm2-release.XXXXXX)"
|
||||
tar -xzf "$ARCHIVE" \
|
||||
-C "$STAGING" --no-same-owner --no-same-permissions
|
||||
test -f "$STAGING/services/docker-compose.yml"
|
||||
rsync -a --delete --exclude=.env --chown=root:root --chmod=D755,F644 \
|
||||
"$STAGING/services/" /opt/han-chat/services/
|
||||
rm -rf -- "$STAGING"
|
||||
OPS_CIDRS='<OPS_PUBLIC_IP>/32' \
|
||||
VM1_PRIVATE_CIDRS='<VM1_PRIVATE_IP>/32' \
|
||||
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
|
||||
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
|
||||
HARDEN_SSH=true SKIP_APT_UPGRADE=true \
|
||||
/root/setup-vm2.sh
|
||||
```
|
||||
|
||||
After root configures `.env`, Selectel encrypted credentials, loader mapping,
|
||||
TLS and CIDR allow-lists, root synchronizes secrets, runs preflight/migrations
|
||||
and performs the first start. Subsequent routine operations available to
|
||||
deploy are limited to:
|
||||
|
||||
```sh
|
||||
sudo systemctl restart han-secrets-vm2.service
|
||||
sudo systemctl restart han-processing.service
|
||||
sudo systemctl --no-pager status han-processing.service
|
||||
sudo journalctl --no-pager -u han-processing.service
|
||||
```
|
||||
|
||||
Exact archive activation, file installation, credential creation, migration
|
||||
and first-start commands are documented in the VM2 Russian runbook referenced
|
||||
above. They must not be replaced with direct Docker access for deploy.
|
||||
|
||||
## Gate 0 — decisions and ownership
|
||||
|
||||
- [ ] Release SHA/digests, maintenance window, on-call and rollback owner recorded.
|
||||
- [ ] RPO/RTO accepted; initial targets are PG RPO <=15 minutes and RTO <=4 hours.
|
||||
- [ ] Remote OTLP backend selected, or debug-only acceptance limitation accepted.
|
||||
- [ ] Mock OTP, Safety stub and bitrix-sync stub risks explicitly accepted.
|
||||
|
||||
## Gate 1 — VPC, DNS and security groups
|
||||
|
||||
- [ ] Managed PostgreSQL has only a private endpoint and accepts traffic from VM SG.
|
||||
- [ ] Internet can reach only VM TCP 80/443; SSH is restricted to VPN/ops CIDR.
|
||||
- [ ] Ports 6379, 4317/4318, 8000, 8080 and 9000 are denied externally.
|
||||
- [ ] DNS `A` for `PUBLIC_HOST` points at the VM and outbound HTTPS is available.
|
||||
|
||||
## Gate 2 — VM hardening
|
||||
|
||||
On a fresh Ubuntu 24.04 VM, run:
|
||||
|
||||
```sh
|
||||
sudo deployment/scripts/setup-vm.sh
|
||||
```
|
||||
|
||||
The script disables password SSH and X11 forwarding by default, then locks the
|
||||
local `root` and `deploy` passwords after checking authorized keys. Before
|
||||
setting `HARDEN_SSH=true`, which also disables root login and TCP forwarding,
|
||||
verify key-based deploy access in a separate SSH session.
|
||||
|
||||
- [ ] Ubuntu 24.04, NTP, unattended security updates and disk alerts are active.
|
||||
- [ ] Key-only deploy account works in a second session; root/password SSH is off.
|
||||
- [ ] UFW/cloud SG and `DOCKER-USER` policy survive reboot.
|
||||
- [ ] Docker Engine and Compose support `include` and long-form `env_file`.
|
||||
|
||||
## Gate 3 — managed PostgreSQL
|
||||
|
||||
- [ ] Daily backup, PITR, deletion protection, encryption and alerts are enabled.
|
||||
- [ ] Provider CA is installed at `PG_CA_HOST_PATH`; all DSNs use `verify-full`.
|
||||
- [ ] Schemas `han_app`, `bitrix_local`, `bitrix_sync`, `message_safety`, `keycloak`
|
||||
have separate migration/runtime roles with tested negative grants.
|
||||
- [ ] Migration tested against an empty DB and a clone of the previous release.
|
||||
|
||||
## Gate 4 — Selectel S3
|
||||
|
||||
- [ ] Quarantine, attachments and documents buckets are private and encrypted.
|
||||
- [ ] API credentials are prefix-scoped; Safety credentials are quarantine read-only.
|
||||
- [ ] Browser CORS permits exact HTTPS origin and PUT headers only.
|
||||
- [ ] Quarantine lifecycle exceeds Safety poll/recovery; data retention is approved.
|
||||
|
||||
## Gate 5 — immutable release
|
||||
|
||||
- [ ] Checkout is detached at the approved SHA and working tree is clean.
|
||||
- [ ] Service images are immutable and scanned; no unresolved critical/high issue.
|
||||
- [ ] Root `docker-compose.yml` is the only deployment entry point.
|
||||
|
||||
## Gate 6 — environment and secrets
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
# Replace non-secret configuration placeholders only.
|
||||
./scripts/validate-env .env
|
||||
sudo systemctl restart han-secrets@production.service
|
||||
sudo ./scripts/validate-env .env \
|
||||
--runtime-manifest /run/han-chat/secrets/manifest
|
||||
sudo deployment/secrets/han-compose config --quiet
|
||||
```
|
||||
|
||||
- [ ] `SECRETS_SOURCE=file|selectel`; `.env` contains no secret keys or credential-bearing DSNs.
|
||||
- [ ] `deployment/secrets/han-secrets` sets `HAN_SECRETS_ACTIVE=1`, does not log values,
|
||||
and optionally exposes a paths-only `HAN_RUNTIME_SECRET_MANIFEST`.
|
||||
- [ ] Runtime token pairs match, PG verifies TLS, public URLs are HTTPS.
|
||||
- [ ] Mock OTP risk is accepted and runtime secrets are unique >=128-bit values.
|
||||
- [ ] `NOTIFICATIONS_TOKEN_PRODUCER_TEST` is unique and supplied only through secret/env; the `producer_test` source seed stores only its hash.
|
||||
- [ ] `FRONTEND_DEV_PROXY_ENABLED=false` and Safety/nginx timeout budgets match.
|
||||
|
||||
## Gate 7 — images and static frontend
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env pull
|
||||
docker compose --env-file .env build --pull frontend-static nginx redis
|
||||
docker compose --env-file .env run --rm frontend-static
|
||||
```
|
||||
|
||||
- [ ] Frontend export was tested/scanned and copied by `frontend-static` into its named volume.
|
||||
- [ ] Build artifacts contain no secrets or unintended source maps.
|
||||
- [ ] At least 30% VM disk remains free.
|
||||
|
||||
## Gate 8 — topology
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env config --services
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
- [ ] Exactly nginx publishes `80:80` and `443:443`; no PostgreSQL service exists.
|
||||
- [ ] Redis AOF/RDB/ACL and OTEL persistent queue volumes are present.
|
||||
- [ ] `backend` and `observability` are internal networks.
|
||||
|
||||
## Gate 9 — ACME/TLS bootstrap
|
||||
|
||||
Set `NGINX_TLS_ENABLED=false` only for this bootstrap command:
|
||||
|
||||
```sh
|
||||
NGINX_TLS_ENABLED=false docker compose --env-file .env up -d nginx
|
||||
docker compose --profile certbot run --rm certbot certonly \
|
||||
--webroot -w /var/www/certbot -d "$PUBLIC_HOST" \
|
||||
--cert-name "$PUBLIC_HOST" --email "$ACME_EMAIL" \
|
||||
--agree-tos --no-eff-email --non-interactive
|
||||
docker compose --env-file .env up -d --force-recreate nginx
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
```
|
||||
|
||||
First rehearse with Certbot `--staging`. Install a twice-daily systemd timer for
|
||||
`deployment/scripts/ssl-renew.sh`; test `certbot renew --dry-run`. Enable HSTS
|
||||
only after chain, hostname, redirect and TLS 1.2/1.3 checks pass.
|
||||
|
||||
## Gate 10 — migrations and seed
|
||||
|
||||
Create a provider PITR marker, then:
|
||||
|
||||
```sh
|
||||
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
|
||||
deployment/scripts/seed.sh
|
||||
```
|
||||
|
||||
- [ ] Expected Alembic revisions are active and runtime users did not perform DDL.
|
||||
- [ ] Seed succeeds twice and mandatory settings contain no secret.
|
||||
- [ ] Schema remains backward-compatible with the previous images.
|
||||
|
||||
## Gate 11 — Keycloak
|
||||
|
||||
```sh
|
||||
docker compose up -d keycloak
|
||||
docker compose ps keycloak
|
||||
```
|
||||
|
||||
- [ ] Discovery/JWKS issuer is the exact public `/auth` HTTPS URL.
|
||||
- [ ] Frontend client is public PKCE S256; implicit/password/social flows are off.
|
||||
- [ ] Wrong/replayed OTP and limits fail safely; settings bridge is fail-closed.
|
||||
- [ ] Bootstrap admin was removed/rotated and named admin MFA is enabled.
|
||||
|
||||
## Gate 12 — ordered startup and readiness
|
||||
|
||||
```sh
|
||||
docker compose up -d redis
|
||||
docker compose up -d keycloak otel-collector
|
||||
docker compose up -d message-safety
|
||||
docker compose up -d api-backend
|
||||
docker compose up -d delivery-worker safety-recovery-worker cleanup-worker \
|
||||
notification-expire-worker notification-draft-cleanup-worker
|
||||
docker compose up -d bitrix-local-app bitrix-sync
|
||||
docker compose up -d nginx
|
||||
docker compose up -d --wait api-backend keycloak sms-service bitrix-local-app
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
docker compose kill -s HUP nginx
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Nginx resolves Docker upstream names when its configuration is loaded. After
|
||||
recreating `api-backend`, `keycloak`, `sms-service`, or `bitrix-local-app`,
|
||||
wait for readiness, validate the active `/tmp/nginx.conf`, and signal the
|
||||
master process with `HUP` as shown above. Do not use bare `nginx -t` or
|
||||
`nginx -s reload`: they target the default config/PID under read-only
|
||||
`/var/run`, not the running Nginx instance.
|
||||
|
||||
- [ ] No restart loop/OOM; critical readiness is green.
|
||||
- [ ] `notification-expire-worker` runs daily closure with an advisory lock; `notification-draft-cleanup-worker` removes expired drafts/S3 objects. Both entrypoints exist in the installed image.
|
||||
- [ ] Only documented Bitrix not-installed/sync-stub degradation remains.
|
||||
- [ ] External `/internal/*` is 404 and OTEL accepts telemetry.
|
||||
|
||||
## Gate 13 — Bitrix24
|
||||
|
||||
- [ ] Install, handler and placement URLs use the exact public HTTPS paths.
|
||||
- [ ] Connector `han_mobile_app` is active on Open Line 8; events are bound once.
|
||||
- [ ] OAuth is encrypted; callback/application/service tokens never enter logs.
|
||||
- [ ] Outbound and operator reply paths are idempotent; internal status is private.
|
||||
|
||||
## Gate 14 — smoke and E2E
|
||||
|
||||
```sh
|
||||
deployment/scripts/smoke.sh
|
||||
```
|
||||
|
||||
- [ ] Guest, OTP/PKCE/bootstrap/session, refresh and logout paths pass.
|
||||
- [ ] Safety allow/deny/pending/timeout and one concurrent slow poll pass.
|
||||
- [ ] File quarantine/promote/delete, owner-only download and audit pass.
|
||||
- [ ] WS reconnect plus REST reconciliation, ownership 404, idempotency and 429 pass.
|
||||
- [ ] Closed-network `producer_test` Create/Cancel smoke passes; identical Create returns `200`, changed payload returns `409`, and the external internal route returns `404`.
|
||||
- [ ] Expire advisory locking and first download of any linked document are verified; hiding is one-time and an existing `date_expired` is preserved.
|
||||
- [ ] Logs contain no PII, message body, token or presigned query.
|
||||
|
||||
## Gate 15 — observability
|
||||
|
||||
- [ ] Known request ID links nginx, API and downstream trace; UX ID is not a label.
|
||||
- [ ] Three signals reach the selected backend; SLO queries and alerts are tested.
|
||||
- [ ] Remote outage fills/drains the bounded persistent queue without business outage.
|
||||
- [ ] Secret/PII canary is absent. Collector restart/drop/refused metrics are checked.
|
||||
|
||||
For local acceptance only, start the redacted debug collector with:
|
||||
`docker compose --profile observability-local up -d otel-collector-local`.
|
||||
|
||||
## Gate 16 — open traffic
|
||||
|
||||
- [ ] Gates 0–15 are signed; fresh backup/PITR evidence and previous images exist.
|
||||
- [ ] HSTS is enabled, release digests/schema/realm versions are recorded.
|
||||
- [ ] No active page; on-call and product owner accept stub limitations.
|
||||
- [ ] Observe 5xx/auth/delivery/DB/Redis/OOM/OTEL queue/Bitrix for 60 minutes.
|
||||
|
||||
## Backup and restore
|
||||
|
||||
Provider backup/PITR is authoritative. A supplemental verified logical dump:
|
||||
|
||||
```sh
|
||||
deployment/scripts/backup.sh /opt/han-chat/backups
|
||||
```
|
||||
|
||||
Quarterly, restore PG and S3 into an isolated VPC, deploy the same image digests,
|
||||
do not route production DNS/Bitrix callbacks, run smoke, and record measured RPO/RTO.
|
||||
Redis may be restored empty; its AOF/RDB is not a business backup.
|
||||
|
||||
## Rollback
|
||||
|
||||
Only roll back to images compatible with the current schema:
|
||||
|
||||
```sh
|
||||
SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
|
||||
deployment/scripts/rollback.sh <PREVIOUS_IMMUTABLE_RELEASE>
|
||||
deployment/scripts/smoke.sh
|
||||
```
|
||||
|
||||
Rollback reuses the current runtime secret set and non-secret config. Do not
|
||||
create or restore an environment snapshot.
|
||||
|
||||
## Real SMS rollout addendum
|
||||
|
||||
This runbook remains mock-only until module-11 artifacts exist. An SMS release requires schema/role `sms`, versioned migrations and an active approved `auth_otp` seed, `sms-service`/worker, the exact callback route, paired service tokens, Direct `TOKEN_1`, approved sender/template, separate callback credentials, a reconfirmed callback source IP, and a static worker egress IP.
|
||||
|
||||
Order: App DB OTP seed → SMS schema/migrations/seed → mock Direct tests → production SMS deployment while Keycloak remains in mock mode → Keycloak expand migration/SPI → controlled provider smoke plus callback/redaction evidence → real mode. Roll back by restoring mock mode without deleting the journal/schema; stop new real orders and drain or record in-flight/`uncertain` rows. Downgrade only with proven schema compatibility.
|
||||
|
||||
Never run Alembic downgrade. After a backward-incompatible migration choose a
|
||||
forward fix or coordinated PITR/S3/Bitrix reconciliation under maintenance.
|
||||
Always verify outbox/inbox/recovery so an ambiguous message is not sent twice.
|
||||
@@ -0,0 +1,271 @@
|
||||
# Инструкция по развертыванию HAN Chat в production-like окружении
|
||||
|
||||
Это исполняемый чек-лист для контура на одной виртуальной машине. PostgreSQL и S3
|
||||
используются как внешние управляемые сервисы. Во время развертывания запрещено
|
||||
использовать `docker compose down -v`, откат миграций Alembic и изменяемые теги образов.
|
||||
|
||||
Подробная пошаговая инструкция для первого запуска находится в
|
||||
`deployment/DEPLOYMENT_GUIDE.ru.md`.
|
||||
|
||||
## Этап 0 — решения и зоны ответственности
|
||||
|
||||
- [ ] Зафиксированы SHA/дайджесты релиза, окно обслуживания, дежурный и ответственный за откат.
|
||||
- [ ] Согласованы RPO/RTO; начальные цели: RPO PostgreSQL не более 15 минут и RTO не более 4 часов.
|
||||
- [ ] Выбран удаленный OTLP-бэкенд либо принято ограничение на использование только отладочного контура.
|
||||
- [ ] Явно приняты риски mock OTP, заглушки Safety и заглушки bitrix-sync.
|
||||
|
||||
## Этап 1 — VPC, DNS и группы безопасности
|
||||
|
||||
- [ ] Управляемый PostgreSQL имеет только приватную точку доступа и принимает трафик от группы безопасности VM.
|
||||
- [ ] Из интернета доступны только TCP-порты VM 80/443; SSH ограничен VPN или CIDR администраторов.
|
||||
- [ ] Порты 6379, 4317/4318, 8000, 8080 и 9000 закрыты для внешнего доступа.
|
||||
- [ ] DNS-запись `A` для `PUBLIC_HOST` указывает на VM; исходящий HTTPS доступен.
|
||||
|
||||
## Этап 2 — защита виртуальной машины
|
||||
|
||||
На новой Ubuntu 24.04 можно выполнить подготовительный скрипт:
|
||||
|
||||
```sh
|
||||
sudo deployment/scripts/setup-vm.sh
|
||||
```
|
||||
|
||||
Скрипт по умолчанию отключает парольный SSH-вход и X11 forwarding, а после
|
||||
проверки ключей блокирует локальные пароли `root` и `deploy`. Перед включением
|
||||
`HARDEN_SSH=true`, которое дополнительно запрещает root-вход и TCP forwarding,
|
||||
обязательно проверьте вход пользователем `deploy` по ключу в отдельной сессии.
|
||||
|
||||
- [ ] Установлена Ubuntu 24.04; работают NTP, автоматические обновления безопасности и оповещения о заполнении диска.
|
||||
- [ ] Вход учетной записью развертывания по ключу проверен во второй сессии; вход root и SSH по паролю отключены.
|
||||
- [ ] Правила UFW/облачной группы безопасности и политика `DOCKER-USER` сохраняются после перезагрузки.
|
||||
- [ ] Docker Engine и Compose поддерживают `include` и полную форму `env_file`.
|
||||
|
||||
## Этап 3 — управляемый PostgreSQL
|
||||
|
||||
- [ ] Включены ежедневные резервные копии, PITR, защита от удаления, шифрование и оповещения.
|
||||
- [ ] CA-сертификат провайдера установлен по пути `PG_CA_HOST_PATH`; все DSN используют `verify-full`.
|
||||
- [ ] Для схем `han_app`, `bitrix_local`, `bitrix_sync`, `message_safety`, `keycloak`
|
||||
созданы отдельные роли миграций и выполнения; запрет лишних прав проверен тестами.
|
||||
- [ ] Миграции проверены на пустой БД и на клоне БД предыдущего релиза.
|
||||
|
||||
## Этап 4 — Selectel S3
|
||||
|
||||
- [ ] Бакеты карантина, вложений и документов закрыты от публичного доступа и зашифрованы.
|
||||
- [ ] Права API ограничены префиксами; учетные данные Safety имеют доступ к карантину только на чтение.
|
||||
- [ ] CORS бакетов разрешает только точный HTTPS-origin браузера и необходимые заголовки PUT.
|
||||
- [ ] Срок хранения карантина превышает время Safety polling/recovery; политика хранения данных согласована.
|
||||
|
||||
## Этап 5 — неизменяемый релиз
|
||||
|
||||
- [ ] Репозиторий переключен на утвержденный SHA в detached-режиме; рабочее дерево чистое.
|
||||
- [ ] Образы сервисов неизменяемы и просканированы; нерешенных критических и высоких уязвимостей нет.
|
||||
- [ ] Корневой `docker-compose.yml` является единственной точкой запуска.
|
||||
|
||||
## Этап 6 — окружение и секреты
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
# Замените только несекретные placeholders.
|
||||
./scripts/validate-env .env
|
||||
sudo systemctl restart han-secrets@production.service
|
||||
sudo ./scripts/validate-env .env \
|
||||
--runtime-manifest /run/han-chat/secrets/manifest
|
||||
sudo deployment/secrets/han-compose config --quiet
|
||||
```
|
||||
|
||||
- [ ] `SECRETS_SOURCE=file|selectel`; `.env` не содержит secret keys и DSN с credentials.
|
||||
- [ ] `deployment/secrets/han-secrets` устанавливает `HAN_SECRETS_ACTIVE=1`,
|
||||
не пишет значения в лог и при возможности передаёт paths-only manifest
|
||||
через `HAN_RUNTIME_SECRET_MANIFEST`.
|
||||
- [ ] Runtime-пары токенов совпадают, PostgreSQL проверяет TLS.
|
||||
- [ ] Риск mock OTP принят; runtime-секреты уникальны и содержат не менее 128 бит энтропии.
|
||||
- [ ] `NOTIFICATIONS_TOKEN_PRODUCER_TEST` сгенерирован отдельно, передан только через secret/env; seed `notification_sources.code='producer_test'` содержит только его hash.
|
||||
- [ ] Установлено `FRONTEND_DEV_PROXY_ENABLED=false`; таймауты Safety и nginx согласованы.
|
||||
|
||||
## Этап 7 — образы и статический frontend
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env pull
|
||||
docker compose --env-file .env build --pull frontend-static nginx redis
|
||||
docker compose --env-file .env run --rm frontend-static
|
||||
```
|
||||
|
||||
- [ ] Экспорт frontend проверен и просканирован, затем скопирован сервисом `frontend-static` в именованный volume.
|
||||
- [ ] Артефакты сборки не содержат секретов и непредусмотренных source map.
|
||||
- [ ] На диске VM остается не менее 30% свободного места.
|
||||
|
||||
## Этап 8 — топология
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env config --services
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
- [ ] Только nginx публикует `80:80` и `443:443`; сервиса PostgreSQL в Compose нет.
|
||||
- [ ] Присутствуют volumes Redis AOF/RDB/ACL и постоянной очереди OTEL.
|
||||
- [ ] Сети `backend` и `observability` являются внутренними.
|
||||
|
||||
## Этап 9 — первоначальная настройка ACME/TLS
|
||||
|
||||
Установите `NGINX_TLS_ENABLED=false` только для команды первоначального запуска:
|
||||
|
||||
```sh
|
||||
NGINX_TLS_ENABLED=false docker compose --env-file .env up -d nginx
|
||||
docker compose --profile certbot run --rm certbot certonly \
|
||||
--webroot -w /var/www/certbot -d "$PUBLIC_HOST" \
|
||||
--cert-name "$PUBLIC_HOST" --email "$ACME_EMAIL" \
|
||||
--agree-tos --no-eff-email --non-interactive
|
||||
docker compose --env-file .env up -d --force-recreate nginx
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
```
|
||||
|
||||
Сначала выполните проверку с параметром Certbot `--staging`. Установите systemd-таймер,
|
||||
запускающий `deployment/scripts/ssl-renew.sh` дважды в сутки, и проверьте
|
||||
`certbot renew --dry-run`. Включайте HSTS только после проверки цепочки сертификатов,
|
||||
имени хоста, перенаправления и поддержки TLS 1.2/1.3.
|
||||
|
||||
## Этап 10 — миграции и начальные данные
|
||||
|
||||
Создайте у провайдера точку восстановления PITR, затем выполните:
|
||||
|
||||
```sh
|
||||
PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh
|
||||
deployment/scripts/seed.sh
|
||||
```
|
||||
|
||||
- [ ] Активны ожидаемые ревизии Alembic; runtime-пользователи не выполняли DDL.
|
||||
- [ ] Повторный seed завершается успешно; обязательные настройки не содержат секретов.
|
||||
- [ ] Схема остается обратно совместимой с образами предыдущего релиза.
|
||||
|
||||
## Этап 11 — Keycloak
|
||||
|
||||
```sh
|
||||
docker compose up -d keycloak
|
||||
docker compose ps keycloak
|
||||
```
|
||||
|
||||
- [ ] Issuer discovery/JWKS точно совпадает с публичным HTTPS URL `/auth`.
|
||||
- [ ] Frontend-клиент является публичным PKCE S256; implicit, password и social flows отключены.
|
||||
- [ ] Неверный или повторно использованный OTP и превышение лимитов безопасно отклоняются; settings bridge работает fail-closed.
|
||||
- [ ] При `KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true` initial send и resend требуют свежий SmartCaptcha token; техническая недоступность Yandex подтверждена как fail-open в логах.
|
||||
- [ ] CSP login-страницы содержит `smartcaptcha.cloud.yandex.ru`/`yastatic.net`, а `/auth/realms/master/protocol/openid-connect/3p-cookies/step2.html` и Admin Console работают без CAPTCHA CSP.
|
||||
- [ ] Временный администратор удален либо его пароль изменен; для именного администратора включена MFA.
|
||||
|
||||
Если предыдущая попытка сохранила custom CSP в realm, сбросьте только это поле через `kcadm`; `.env` как shell-файл не загружать:
|
||||
|
||||
```sh
|
||||
docker compose exec -T keycloak sh -lc '
|
||||
set -eu
|
||||
cfg=/tmp/han-kcadm.config
|
||||
/opt/keycloak/bin/kcadm.sh config credentials --config "$cfg" \
|
||||
--server http://127.0.0.1:8080/auth --realm master \
|
||||
--user "$KC_BOOTSTRAP_ADMIN_USERNAME" \
|
||||
--password "$KC_BOOTSTRAP_ADMIN_PASSWORD"
|
||||
/opt/keycloak/bin/kcadm.sh update realms/han-chat --config "$cfg" \
|
||||
-s "browserSecurityHeaders.contentSecurityPolicy="
|
||||
rm -f "$cfg"
|
||||
'
|
||||
```
|
||||
|
||||
## Этап 12 — последовательный запуск и готовность
|
||||
|
||||
```sh
|
||||
docker compose up -d redis
|
||||
docker compose up -d keycloak otel-collector
|
||||
docker compose up -d message-safety
|
||||
docker compose up -d api-backend
|
||||
docker compose up -d delivery-worker safety-recovery-worker cleanup-worker \
|
||||
notification-expire-worker notification-draft-cleanup-worker
|
||||
docker compose up -d bitrix-local-app bitrix-sync
|
||||
docker compose up -d nginx
|
||||
docker compose up -d --wait api-backend keycloak sms-service bitrix-local-app
|
||||
docker compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
docker compose kill -s HUP nginx
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Nginx разрешает Docker DNS имена upstream при загрузке конфигурации. После
|
||||
любого пересоздания `api-backend`, `keycloak`, `sms-service` или
|
||||
`bitrix-local-app` дождитесь их readiness, проверьте именно рабочий
|
||||
`/tmp/nginx.conf` и отправьте master-процессу `HUP`, как показано выше.
|
||||
Обычные `nginx -t` и `nginx -s reload` использовать нельзя: они обращаются к
|
||||
дефолтному config/PID в read-only `/var/run` и не перезагружают рабочий Nginx.
|
||||
|
||||
- [ ] Нет циклических перезапусков и OOM; критические readiness-проверки успешны.
|
||||
- [ ] `notification-expire-worker` выполняет ежедневное закрытие с advisory lock; `notification-draft-cleanup-worker` очищает просроченные drafts/S3. Оба entrypoint присутствуют в установленном образе.
|
||||
- [ ] Сохраняется только документированная деградация: Bitrix не установлен и bitrix-sync работает как заглушка.
|
||||
- [ ] Внешний запрос `/internal/*` возвращает 404; OTEL принимает телеметрию.
|
||||
|
||||
## Этап 13 — Bitrix24
|
||||
|
||||
- [ ] URL установки, обработчика и placement используют точные публичные HTTPS-пути.
|
||||
- [ ] Коннектор `han_mobile_app` активен в Открытой линии 8; события привязаны однократно.
|
||||
- [ ] OAuth зашифрован; callback-, application- и service-токены не попадают в логи.
|
||||
- [ ] Исходящие сообщения и ответы оператора идемпотентны; внутренний статус не опубликован наружу.
|
||||
|
||||
## Этап 14 — smoke- и E2E-тесты
|
||||
|
||||
```sh
|
||||
deployment/scripts/smoke.sh
|
||||
```
|
||||
|
||||
- [ ] Успешны сценарии гостя, OTP/PKCE/bootstrap/session, обновления токена и выхода.
|
||||
- [ ] Проверены Safety allow/deny/pending/timeout и один параллельный медленный poll.
|
||||
- [ ] Проверены карантин, перенос и удаление файлов, скачивание только владельцем и аудит.
|
||||
- [ ] Проверены переподключение WS с REST-сверкой, 404 при обращении к чужому ресурсу, идемпотентность и 429.
|
||||
- [ ] От имени `producer_test` выполнены Create и Cancel через закрытый `/internal/notifications/v1/*`; тот же Create вернул `200`, изменённый payload — `409`, внешний запрос — `404`.
|
||||
- [ ] Проверены expire job с advisory lock и первое скачивание любого связанного документа: уведомление скрывается один раз, а исходный `date_expired` не перезаписывается.
|
||||
- [ ] Логи не содержат PII, текстов сообщений, токенов и query-параметров presigned URL.
|
||||
|
||||
## Этап 15 — наблюдаемость
|
||||
|
||||
- [ ] Известный request ID связывает трассировку nginx, API и downstream-сервисов; UX ID не используется как label.
|
||||
- [ ] Все три сигнала поступают в выбранный бэкенд; SLO-запросы и оповещения проверены.
|
||||
- [ ] При недоступности удаленного сервиса ограниченная постоянная очередь заполняется и опустошается без остановки бизнес-функций.
|
||||
- [ ] Тестовые секреты и PII отсутствуют; проверены метрики перезапуска, потерь, отказов и очереди Collector.
|
||||
|
||||
Только для локальной приемки запустите отладочный Collector с удалением чувствительных данных:
|
||||
`docker compose --profile observability-local up -d otel-collector-local`.
|
||||
|
||||
## Этап 16 — открытие трафика
|
||||
|
||||
- [ ] Этапы 0–15 подписаны; имеются свежие подтверждения backup/PITR и предыдущие образы.
|
||||
- [ ] HSTS включен; дайджесты релиза, версии схем и realm зафиксированы.
|
||||
- [ ] Активных инцидентов нет; дежурный и владелец продукта приняли ограничения заглушек.
|
||||
- [ ] В течение 60 минут контролируются 5xx, auth, доставка, БД, Redis, OOM, очередь OTEL и Bitrix.
|
||||
|
||||
## Резервное копирование и восстановление
|
||||
|
||||
Основным механизмом являются backup/PITR провайдера. Дополнительный проверенный логический дамп:
|
||||
|
||||
```sh
|
||||
deployment/scripts/backup.sh /opt/han-chat/backups
|
||||
```
|
||||
|
||||
Ежеквартально восстанавливайте PostgreSQL и S3 в изолированной VPC, развертывайте те же
|
||||
дайджесты образов, не направляйте туда production DNS и callbacks Bitrix, выполняйте
|
||||
smoke-тесты и фиксируйте фактические RPO/RTO. Redis можно восстановить пустым:
|
||||
его AOF/RDB не является резервной копией бизнес-данных.
|
||||
|
||||
## Откат
|
||||
|
||||
Откатывайтесь только на образы, совместимые с текущей схемой:
|
||||
|
||||
```sh
|
||||
SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
|
||||
deployment/scripts/rollback.sh <PREVIOUS_IMMUTABLE_RELEASE>
|
||||
deployment/scripts/smoke.sh
|
||||
```
|
||||
|
||||
Откат использует текущие runtime-секреты и текущий несекретный config. Snapshot
|
||||
старого `.env` не создаётся и не восстанавливается.
|
||||
|
||||
## Дополнение: rollout реальной SMS-авторизации
|
||||
|
||||
Текущий runbook остаётся mock-only, пока артефакты module-11 не реализованы. Для SMS release обязательны: schema/role `sms`, migrations/seed active approved `auth_otp`, `sms-service`/worker, exact callback route, парные service tokens, Direct `TOKEN_1`, согласованные sender/template, отдельные callback credentials, подтверждённый callback source IP и статический egress IP worker.
|
||||
|
||||
Порядок: App DB OTP seed → SMS schema/migrations/seed → test с mock Direct → production SMS deploy при `KEYCLOAK_OTP_MOCK_ENABLED=true` → Keycloak expand migration/SPI → provider smoke и callback/redaction evidence → real mode. Rollback: вернуть mock, не удалять journal/schema, остановить новые real orders и зафиксировать in-flight/`uncertain`; downgrade только при доказанной совместимости.
|
||||
|
||||
Никогда не выполняйте downgrade Alembic. После обратно несовместимой миграции используйте
|
||||
исправление вперед либо согласованный PITR с восстановлением S3 и сверкой Bitrix во время
|
||||
технического обслуживания. Всегда проверяйте outbox, inbox и recovery, чтобы сообщение
|
||||
с неопределенным статусом не было отправлено повторно.
|
||||
@@ -0,0 +1,51 @@
|
||||
schema_version: 1
|
||||
settings:
|
||||
auth.phone.enabled: {type: boolean, value: true, public: true}
|
||||
auth.password.enabled: {type: boolean, value: false, public: true}
|
||||
otp.phone.max_send_attempts_per_24h: {type: integer, value: 3, public: false}
|
||||
otp.phone.min_seconds_between_attempts: {type: integer, value: 30, public: false}
|
||||
otp.phone.max_verify_attempts: {type: integer, value: 5, public: false}
|
||||
otp.phone.code_length: {type: integer, value: 6, public: false}
|
||||
otp.phone.ttl_seconds: {type: integer, value: 60, public: false}
|
||||
otp.phone.sms_order_timeout_ms: {type: integer, value: 3000, public: false}
|
||||
operator.call.phone: {type: string, value: "+74999591007", public: true}
|
||||
consent.personal_data.required: {type: boolean, value: true, public: true}
|
||||
consent.personal_data.document_url: {type: string, value: "https://www.han0107.ru/privacy/persdata-agree-mobile", public: true}
|
||||
consent.personal_data.version: {type: string, value: "2026-06-10", public: true}
|
||||
consent.privacy_policy.document_url: {type: string, value: "https://www.han0107.ru/privacy", public: true}
|
||||
consent.user_agreement.required: {type: boolean, value: true, public: true}
|
||||
consent.user_agreement.document_url: {type: string, value: "https://www.han0107.ru/user-agreement", public: true}
|
||||
consent.user_agreement.version: {type: string, value: "2026-06-10", public: true}
|
||||
consent.marketing.required: {type: boolean, value: false, public: true}
|
||||
consent.marketing.document_url: {type: string, value: "https://www.han0107.ru/privacy/ads-agree", public: true}
|
||||
consent.marketing.version: {type: string, value: "2026-06-10", public: true}
|
||||
chat.message.max_length: {type: integer, value: 4000, public: true}
|
||||
chat.attachments.allowed_extensions: {type: string_list, value: "jpg,jpeg,png,webp,heic,heif,pdf", public: true}
|
||||
chat.attachments.allowed_mime_types: {type: string_list, value: "image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf", public: true}
|
||||
chat.attachments.disallowed_extensions: {type: string_list, value: "svg,doc,docx,xls,xlsx,csv", public: false}
|
||||
chat.attachments.max_size_mb: {type: integer, value: 5, public: true}
|
||||
chat.attachments.storage: {type: string, value: selectel_s3, public: false}
|
||||
chat.attachments.upload_mode: {type: string, value: presigned_put, public: false}
|
||||
chat.attachments.safety_scan_required: {type: boolean, value: true, public: false}
|
||||
chat.attachments.presigned_upload_ttl_seconds: {type: integer, value: 600, public: true}
|
||||
rate_limit.message_send.per_user: {type: string, value: "30/minute", public: true}
|
||||
rate_limit.message_send.per_dialog: {type: string, value: "20/minute", public: false}
|
||||
rate_limit.download_url.per_user: {type: string, value: "60/hour", public: false}
|
||||
rate_limit.public_endpoints.per_ip: {type: string, value: "60/minute", public: true}
|
||||
rate_limit.login.per_ip: {type: string, value: "10/minute", public: true}
|
||||
rate_limit.notifications_read.per_user: {type: string, value: "120/minute", public: false}
|
||||
rate_limit.notifications_action.per_user: {type: string, value: "60/minute", public: false}
|
||||
rate_limit.notification_upload.per_user: {type: string, value: "20/minute", public: false}
|
||||
rate_limit.notifications_public.per_ip: {type: string, value: "60/minute", public: false}
|
||||
notification.home.max_items: {type: integer, value: 7, public: false}
|
||||
notification.center.max_items: {type: integer, value: 15, public: false}
|
||||
notification.carousel.autoplay_enabled: {type: boolean, value: false, public: true}
|
||||
notification.carousel.autoplay_interval_ms: {type: integer, value: 5000, public: true}
|
||||
notification.hidden.default_ttl_days: {type: integer, value: 3, public: false}
|
||||
notification.documents.max_files: {type: integer, value: 10, public: false}
|
||||
notification.instruction.allowed_hosts: {type: string_list, value: "chat.example.ru", public: false}
|
||||
notification.expire_job.run_at: {type: string, value: "00:01", public: false}
|
||||
notification.upload_draft.ttl_days: {type: integer, value: 7, public: false}
|
||||
ux.session.idle_timeout_minutes: {type: integer, value: 30, public: true}
|
||||
security.cors.allowed_origins: {type: string_list, value: "https://chat.example.ru", public: false}
|
||||
security.public_cache.max_age_seconds: {type: integer, value: 3600, public: false}
|
||||
@@ -0,0 +1,147 @@
|
||||
x-api-job-secrets: &api-job-secrets
|
||||
- api_database_url
|
||||
- api_redis_url
|
||||
- api_redis_realtime_url
|
||||
- message_safety_service_token
|
||||
- bitrix_local_app_internal_token
|
||||
- bitrix_api_inbox_token
|
||||
- keycloak_settings_bridge_token
|
||||
- selectel_s3_access_key
|
||||
- selectel_s3_secret_key
|
||||
- cursor_hmac_secret
|
||||
|
||||
x-api-job-environment: &api-job-environment
|
||||
HAN_SECRET_VARS: >-
|
||||
DATABASE_URL REDIS_URL REDIS_REALTIME_URL MESSAGE_SAFETY_SERVICE_TOKEN
|
||||
BITRIX_LOCAL_APP_INTERNAL_TOKEN BITRIX_API_INBOX_TOKEN
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN SELECTEL_S3_ACCESS_KEY SELECTEL_S3_SECRET_KEY
|
||||
CURSOR_HMAC_SECRET
|
||||
DATABASE_URL_FILE: /run/secrets/api_database_url
|
||||
REDIS_URL_FILE: /run/secrets/api_redis_url
|
||||
REDIS_REALTIME_URL_FILE: /run/secrets/api_redis_realtime_url
|
||||
MESSAGE_SAFETY_SERVICE_TOKEN_FILE: /run/secrets/message_safety_service_token
|
||||
BITRIX_LOCAL_APP_INTERNAL_TOKEN_FILE: /run/secrets/bitrix_local_app_internal_token
|
||||
BITRIX_API_INBOX_TOKEN_FILE: /run/secrets/bitrix_api_inbox_token
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN_FILE: /run/secrets/keycloak_settings_bridge_token
|
||||
SELECTEL_S3_ACCESS_KEY_FILE: /run/secrets/selectel_s3_access_key
|
||||
SELECTEL_S3_SECRET_KEY_FILE: /run/secrets/selectel_s3_secret_key
|
||||
CURSOR_HMAC_SECRET_FILE: /run/secrets/cursor_hmac_secret
|
||||
APP_ENV: ${APP_ENV:-production-like}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL}
|
||||
KEYCLOAK_INTERNAL_URL: ${KEYCLOAK_INTERNAL_URL:-http://keycloak:8080/auth}
|
||||
KEYCLOAK_REALM: ${KEYCLOAK_REALM:-han-chat}
|
||||
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE:-han-chat-api}
|
||||
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL}
|
||||
MESSAGE_SAFETY_API_PREFIX: /internal/safety/v2
|
||||
MESSAGE_SAFETY_CA_FILE: /run/config/message-safety-internal-ca.pem
|
||||
MESSAGE_SAFETY_POST_TIMEOUT_SEC: ${MESSAGE_SAFETY_POST_TIMEOUT_SEC:-5}
|
||||
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC: ${MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC:-2}
|
||||
MESSAGE_SAFETY_TASK_POLL_MAX_SEC: ${MESSAGE_SAFETY_TASK_POLL_MAX_SEC:-300}
|
||||
BITRIX_LOCAL_APP_BASE_URL: ${BITRIX_LOCAL_APP_BASE_URL:-http://bitrix-local-app:8080}
|
||||
BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC: ${BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC:-20}
|
||||
SELECTEL_S3_ENDPOINT_URL: ${SELECTEL_S3_ENDPOINT_URL}
|
||||
SELECTEL_S3_BUCKET_DOCUMENTS: ${SELECTEL_S3_BUCKET_DOCUMENTS}
|
||||
SELECTEL_S3_BUCKET_ATTACHMENTS: ${SELECTEL_S3_BUCKET_ATTACHMENTS}
|
||||
SELECTEL_S3_BUCKET_QUARANTINE: ${SELECTEL_S3_BUCKET_QUARANTINE}
|
||||
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.1/32}
|
||||
|
||||
services:
|
||||
migrate-api:
|
||||
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
|
||||
profiles: ["ops"]
|
||||
environment:
|
||||
HAN_SECRET_VARS: DATABASE_URL
|
||||
DATABASE_URL_FILE: /run/secrets/api_database_url
|
||||
secrets:
|
||||
- api_database_url
|
||||
command: ["alembic", "upgrade", "head"]
|
||||
volumes:
|
||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||
networks: [backend, egress]
|
||||
restart: "no"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
|
||||
migrate-bitrix-local:
|
||||
image: ${BITRIX_LOCAL_APP_IMAGE:-han-chat-bitrix-local-app:local}
|
||||
profiles: ["ops"]
|
||||
environment:
|
||||
HAN_SECRET_VARS: BITRIX_DATABASE_URL
|
||||
BITRIX_DATABASE_URL_FILE: /run/secrets/bitrix_database_url
|
||||
secrets:
|
||||
- bitrix_database_url
|
||||
command: ["alembic", "upgrade", "head"]
|
||||
volumes:
|
||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||
networks: [backend, egress]
|
||||
restart: "no"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
|
||||
migrate-bitrix-sync:
|
||||
image: ${BITRIX_SYNC_IMAGE:-han-chat-bitrix-sync:local}
|
||||
profiles: ["ops"]
|
||||
environment:
|
||||
HAN_SECRET_VARS: BITRIX_SYNC_DATABASE_URL
|
||||
BITRIX_SYNC_DATABASE_URL_FILE: /run/secrets/bitrix_sync_database_url
|
||||
secrets:
|
||||
- bitrix_sync_database_url
|
||||
command: ["alembic", "upgrade", "head"]
|
||||
volumes:
|
||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||
networks: [backend, egress]
|
||||
restart: "no"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
|
||||
migrate-sms:
|
||||
image: ${SMS_SERVICE_IMAGE:-han-chat-sms-service:local}
|
||||
profiles: ["ops"]
|
||||
environment:
|
||||
HAN_SECRET_VARS: SMS_DATABASE_URL
|
||||
SMS_DATABASE_URL_FILE: /run/secrets/sms_database_url
|
||||
secrets:
|
||||
- sms_database_url
|
||||
command: ["alembic", "upgrade", "head"]
|
||||
volumes:
|
||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||
networks: [backend, egress]
|
||||
restart: "no"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
|
||||
seed-settings:
|
||||
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
|
||||
profiles: ["ops"]
|
||||
environment: *api-job-environment
|
||||
secrets: *api-job-secrets
|
||||
command:
|
||||
- /bin/sh
|
||||
- -ec
|
||||
- >-
|
||||
python -m app.cli.seed_settings
|
||||
--file /deployment/app-settings.production-like.yaml
|
||||
&& python -m app.cli.validate_settings
|
||||
volumes:
|
||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||
- ${MESSAGE_SAFETY_CA_HOST_PATH}:/run/config/message-safety-internal-ca.pem:ro
|
||||
- ./app-settings.production-like.yaml:/deployment/app-settings.production-like.yaml:ro
|
||||
networks: [backend, egress]
|
||||
restart: "no"
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
ulimits:
|
||||
core: {soft: 0, hard: 0}
|
||||
|
||||
toolbox:
|
||||
image: curlimages/curl:8.11.1
|
||||
profiles: ["ops"]
|
||||
entrypoint: ["sleep", "infinity"]
|
||||
networks: [backend, observability, egress]
|
||||
restart: "no"
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
CONFIG_FILE=${CONFIG_FILE:-.env}
|
||||
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
|
||||
|
||||
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
|
||||
[ -x "$SECRETS_LAUNCHER" ] || {
|
||||
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
|
||||
exit 66
|
||||
}
|
||||
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
|
||||
fi
|
||||
|
||||
if [ -z "${PG_BACKUP_DSN_FILE:-}" ] || [ ! -r "$PG_BACKUP_DSN_FILE" ]; then
|
||||
echo "PG_BACKUP_DSN_FILE is required from the secret launcher." >&2
|
||||
exit 64
|
||||
fi
|
||||
PG_BACKUP_DSN=$(cat "$PG_BACKUP_DSN_FILE")
|
||||
case "$PG_BACKUP_DSN" in
|
||||
*sslmode=verify-full*sslrootcert=*) ;;
|
||||
*) echo "PG_BACKUP_DSN must enforce sslmode=verify-full and sslrootcert." >&2; exit 64 ;;
|
||||
esac
|
||||
|
||||
output_dir=${1:-/opt/han-chat/backups}
|
||||
umask 077
|
||||
mkdir -p "$output_dir"
|
||||
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
archive="$output_dir/han-chat-$stamp.dump"
|
||||
|
||||
PGDATABASE="$PG_BACKUP_DSN" pg_dump \
|
||||
--format=custom --no-owner --no-privileges --file="$archive"
|
||||
unset PG_BACKUP_DSN
|
||||
pg_restore --list "$archive" >/dev/null
|
||||
sha256sum "$archive" > "$archive.sha256"
|
||||
chmod 600 "$archive" "$archive.sha256"
|
||||
echo "Logical backup verified: $archive"
|
||||
echo "This supplements, but does not replace, provider backup/PITR and restore rehearsal."
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
CONFIG_FILE=${CONFIG_FILE:-.env}
|
||||
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
|
||||
|
||||
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
|
||||
[ -x "$SECRETS_LAUNCHER" ] || {
|
||||
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
|
||||
exit 66
|
||||
}
|
||||
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
|
||||
fi
|
||||
|
||||
if [ "${PITR_MARKER_CONFIRMED:-false}" != "true" ]; then
|
||||
echo "Refusing migration: create provider PITR marker, then set PITR_MARKER_CONFIRMED=true" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
if [ -n "${HAN_RUNTIME_SECRET_MANIFEST:-}" ]; then
|
||||
./scripts/validate-env "$CONFIG_FILE" --runtime-manifest "$HAN_RUNTIME_SECRET_MANIFEST"
|
||||
else
|
||||
./scripts/validate-env "$CONFIG_FILE" --runtime-env
|
||||
fi
|
||||
docker compose --env-file "$CONFIG_FILE" config --quiet
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-api alembic current
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-local alembic current
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-sync alembic current
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-sms alembic current
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-api
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-local
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-sync
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-sms
|
||||
echo "Migrations completed; record revisions in release evidence."
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
CONFIG_FILE=${CONFIG_FILE:-.env}
|
||||
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
|
||||
|
||||
previous_release=${1:-}
|
||||
if [ -z "$previous_release" ]; then
|
||||
echo "Usage: $0 <previous-immutable-release>" >&2
|
||||
exit 64
|
||||
fi
|
||||
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
|
||||
[ -x "$SECRETS_LAUNCHER" ] || {
|
||||
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
|
||||
exit 66
|
||||
}
|
||||
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
|
||||
fi
|
||||
if [ "${SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED:-false}" != "true" ]; then
|
||||
echo "Refusing rollback: set SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true after migration review." >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
if [ -n "${HAN_RUNTIME_SECRET_MANIFEST:-}" ]; then
|
||||
./scripts/validate-env "$CONFIG_FILE" --runtime-manifest "$HAN_RUNTIME_SECRET_MANIFEST"
|
||||
else
|
||||
./scripts/validate-env "$CONFIG_FILE" --runtime-env
|
||||
fi
|
||||
RELEASE_VERSION="$previous_release" docker compose --env-file "$CONFIG_FILE" config --quiet
|
||||
RELEASE_VERSION="$previous_release" docker compose --env-file "$CONFIG_FILE" up -d --remove-orphans
|
||||
docker compose --env-file "$CONFIG_FILE" ps
|
||||
|
||||
echo "Application images rolled back without Alembic downgrade."
|
||||
echo "Run deployment/scripts/smoke.sh and verify outbox/inbox idempotency."
|
||||
@@ -0,0 +1,448 @@
|
||||
#!/usr/bin/env bash
|
||||
# Создаёт 9 персональных уведомлений всех видов контура P для одного user_id.
|
||||
# Запускать на ВМ из каталога backend: /opt/han-chat/backend
|
||||
#
|
||||
# cd /opt/han-chat/backend
|
||||
# sed -i 's/\r$//' deployment/scripts/seed-personal-notifications-test.sh
|
||||
# chmod +x deployment/scripts/seed-personal-notifications-test.sh
|
||||
# ./deployment/scripts/seed-personal-notifications-test.sh
|
||||
#
|
||||
# Токен берётся из .env (NOTIFICATIONS_TOKEN_PRODUCER_TEST) — тот же, что у api-backend.
|
||||
# При старте api-backend синхронизирует hash токена в notification_sources.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
ENV_FILE="${ENV_FILE:-.env}"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Не найден $ENV_FILE. Запускайте из каталога backend." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
trim_token() {
|
||||
printf '%s' "$1" | tr -d '\r\n\t '
|
||||
}
|
||||
|
||||
TOKEN="$(trim_token "${NOTIFICATIONS_TOKEN_PRODUCER_TEST:-}")"
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
TOKEN="$(trim_token "$(env_value NOTIFICATIONS_TOKEN_PRODUCER_TEST 2>/dev/null || true)")"
|
||||
fi
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
read -rsp "NOTIFICATIONS_TOKEN_PRODUCER_TEST (из .env не найден): " TOKEN
|
||||
echo
|
||||
TOKEN="$(trim_token "$TOKEN")"
|
||||
fi
|
||||
|
||||
read -rp "USER_ID клиента: " USER_ID
|
||||
USER_ID="$(trim_token "$USER_ID")"
|
||||
|
||||
if [[ -z "${TOKEN}" || -z "${USER_ID}" ]]; then
|
||||
echo "TOKEN и USER_ID обязательны." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Токен: ${#TOKEN} символов (первые 8: ${TOKEN:0:8}…)"
|
||||
|
||||
PAYLOAD_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$PAYLOAD_DIR"' EXIT
|
||||
|
||||
BASE_TS="$(date +%s)"
|
||||
RUN_ID="manual-test-${BASE_TS}"
|
||||
|
||||
NOW="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_URGENT="$(date -u -d "${NOW} +0 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+0S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_PAYMENT="$(date -u -d "${NOW} - 60 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-60S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_DOCS_REQ="$(date -u -d "${NOW} - 120 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-120S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_DOCS_READY="$(date -u -d "${NOW} - 180 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-180S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_STATUS="$(date -u -d "${NOW} - 240 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-240S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_REMINDER="$(date -u -d "${NOW} - 300 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-300S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_NEWS="$(date -u -d "${NOW} - 360 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-360S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_PROMO="$(date -u -d "${NOW} - 420 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-420S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DT_ADS="$(date -u -d "${NOW} - 480 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v-480S +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
DEADLINE_URGENT="$(date -u -d "${NOW} + 2 days" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+2d +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DEADLINE_DOCS="$(date -u -d "${NOW} + 5 days" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+5d +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DEADLINE_REMINDER="$(date -u -d "${NOW} + 1 day" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+1d +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
EXPIRE_PAYMENT="$(date -u -d "${NOW} + 3 days" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+3d +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
write_payload() {
|
||||
local name="$1"
|
||||
cat > "${PAYLOAD_DIR}/${name}.json"
|
||||
}
|
||||
|
||||
create_notification() {
|
||||
local label="$1"
|
||||
local payload_file="${PAYLOAD_DIR}/${label}.json"
|
||||
if [[ ! -f "$payload_file" ]]; then
|
||||
echo "Нет payload: ${payload_file}" >&2
|
||||
return 1
|
||||
fi
|
||||
echo
|
||||
echo "========== ${label} =========="
|
||||
docker run --rm \
|
||||
--network han-chat-backend \
|
||||
-v "${payload_file}:/payload.json:ro" \
|
||||
curlimages/curl:latest \
|
||||
-sS -i \
|
||||
-X POST \
|
||||
'http://api-backend:8000/internal/notifications/v1/notifications' \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H 'Content-Type: application/json; charset=utf-8' \
|
||||
--data-binary @/payload.json
|
||||
}
|
||||
|
||||
prepare_docs_in_s3() {
|
||||
echo >&2
|
||||
echo "========== PREP: загрузка тестовых документов в S3 для docs_ready ==========" >&2
|
||||
docker compose --env-file "$ENV_FILE" exec -T \
|
||||
-e "USER_ID=${USER_ID}" \
|
||||
-e "RUN_ID=${RUN_ID}" \
|
||||
api-backend python3 - <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
|
||||
from app.settings import Settings
|
||||
|
||||
USER_ID = os.environ["USER_ID"]
|
||||
settings = Settings()
|
||||
|
||||
client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=str(settings.selectel_s3_endpoint_url),
|
||||
aws_access_key_id=settings.selectel_s3_access_key.get_secret_value(),
|
||||
aws_secret_access_key=settings.selectel_s3_secret_key.get_secret_value(),
|
||||
config=Config(signature_version="s3v4", s3={"addressing_style": "virtual"}),
|
||||
)
|
||||
bucket = settings.selectel_s3_bucket_documents
|
||||
|
||||
fixtures = [
|
||||
{
|
||||
"prefix": "CONTRACT",
|
||||
"title": "Уведомление о постановке на миграционный учёт.pdf",
|
||||
"body": b"""%PDF-1.4
|
||||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||
3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj
|
||||
xref
|
||||
0 4
|
||||
trailer<</Size 4/Root 1 0 R>>
|
||||
startxref
|
||||
100
|
||||
%%EOF
|
||||
""",
|
||||
},
|
||||
{
|
||||
"prefix": "RESULTS",
|
||||
"title": "Справка о соблюдении миграционного законодательства.pdf",
|
||||
"body": b"""%PDF-1.4
|
||||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||
3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj
|
||||
xref
|
||||
0 4
|
||||
trailer<</Size 4/Root 1 0 R>>
|
||||
startxref
|
||||
100
|
||||
%%EOF
|
||||
""",
|
||||
},
|
||||
]
|
||||
|
||||
lines: list[str] = []
|
||||
for item in fixtures:
|
||||
doc_id = str(uuid.uuid4())
|
||||
key = f"documents/users/{USER_ID}/{doc_id}"
|
||||
body = item["body"]
|
||||
checksum = hashlib.sha256(body).hexdigest()
|
||||
client.put_object(
|
||||
Bucket=bucket,
|
||||
Key=key,
|
||||
Body=body,
|
||||
ContentType="application/pdf",
|
||||
)
|
||||
p = item["prefix"]
|
||||
lines.append(
|
||||
f"{p}_KEY={key}\n"
|
||||
f"{p}_TITLE={item['title']}\n"
|
||||
f"{p}_SIZE={len(body)}\n"
|
||||
f"{p}_SHA256={checksum}"
|
||||
)
|
||||
|
||||
print("\n".join(lines))
|
||||
PY
|
||||
}
|
||||
|
||||
echo "Run ID: ${RUN_ID}"
|
||||
echo "User ID: ${USER_ID}"
|
||||
|
||||
read -rp "Подготовить документы в S3 для docs_ready? [Y/n]: " PREP_DOCS
|
||||
PREP_DOCS="${PREP_DOCS:-Y}"
|
||||
|
||||
CONTRACT_KEY=""
|
||||
CONTRACT_TITLE=""
|
||||
CONTRACT_SIZE=""
|
||||
CONTRACT_SHA256=""
|
||||
RESULTS_KEY=""
|
||||
RESULTS_TITLE=""
|
||||
RESULTS_SIZE=""
|
||||
RESULTS_SHA256=""
|
||||
|
||||
if [[ "${PREP_DOCS^^}" != "N" ]]; then
|
||||
PREP_OUT="$(prepare_docs_in_s3)"
|
||||
echo "${PREP_OUT}"
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]] || continue
|
||||
declare "${BASH_REMATCH[1]}=${BASH_REMATCH[2]}"
|
||||
done <<< "${PREP_OUT}"
|
||||
fi
|
||||
|
||||
# --- payloads ---
|
||||
|
||||
write_payload urgent <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "urgent",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-urgent",
|
||||
"notification_datetime": "${DT_URGENT}",
|
||||
"header": "Истекает срок постановки на учёт",
|
||||
"text": "По данным сервиса, срок уведомления о месте пребывания истекает 29 июля — просрочка влечёт административную ответственность.",
|
||||
"priority_override": 1,
|
||||
"date_expired": "${DEADLINE_URGENT}",
|
||||
"details": {
|
||||
"deadline": "${DEADLINE_URGENT}",
|
||||
"details_header": "Срочно: соблюдение сроков миграционного учёта",
|
||||
"details_text": "Федеральный закон № 109-ФЗ обязывает иностранного гражданина в течение 7 рабочих дней с даты въезда подать уведомление о прибытии в место пребывания (если иное не предусмотрено для вашего правового статуса).\n\nПо имеющимся данным крайний срок для вашего случая — 29.07.2026. Нарушение сроков может повлечь штраф от 2 000 до 5 000 ₽ и, при повторном нарушении, более серьёзные последствия, включая административное выдворение.\n\nЕсли уведомление уже подано — отметьте «Готово»; если нужна помощь — напишите оператору в чат.",
|
||||
"todo_header": "Что проверить сейчас",
|
||||
"todo_plan": [
|
||||
{"number": 1, "text": "Сверьте дату въезда и адрес фактического проживания в анкете личного кабинета."},
|
||||
{"number": 2, "text": "Подготовьте копии паспорта, миграционной карты и документа о праве пребывания (виза, РВП, ВНЖ, патент)."},
|
||||
{"number": 3, "text": "Нажмите «Готово», если уведомление уже подано через МВД или принимающую сторону."},
|
||||
{"number": 4, "text": "При сомнениях выберите «Сделаю позже» и задайте вопрос оператору — укажите город и тип документа."}
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
write_payload payment_pending <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "payment_pending",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-payment_pending",
|
||||
"notification_datetime": "${DT_PAYMENT}",
|
||||
"header": "Оплата сопровождения по миграционному учёту",
|
||||
"text": "Счёт №МИГ-2026-0718 на 18 500 ₽ за подготовку пакета и подачу уведомления — оплатите до 30 июля.",
|
||||
"price": "18500.00",
|
||||
"old_price": "22000.00",
|
||||
"date_expired": "${EXPIRE_PAYMENT}",
|
||||
"payment_url": "https://pay.han0107.ru/checkout/test-${RUN_ID}"
|
||||
}
|
||||
JSON
|
||||
|
||||
write_payload docs_required <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "docs_required",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-docs_required",
|
||||
"notification_datetime": "${DT_DOCS_REQ}",
|
||||
"header": "Загрузите документы для миграционного учёта",
|
||||
"text": "Для проверки соблюдения миграционного законодательства нужен комплект документов — загрузите до 01 августа.",
|
||||
"date_expired": "${DEADLINE_DOCS}",
|
||||
"details": {
|
||||
"deadline": "${DEADLINE_DOCS}",
|
||||
"details_header": "Документы для постановки на миграционный учёт",
|
||||
"details_text": "Специалист проверит комплект в течение 1 рабочего дня после отправки. Принимаются чёткие фото или сканы в JPG, PNG, PDF. Каждый файл — до 10 МБ, не более 10 файлов за одну отправку.\n\nВсе документы должны быть действительными на дату проверки. Если какого-то документа пока нет (например, договор найма ещё не подписан), загрузите доступные — оператор подскажет порядок действий и допустимые альтернативы.",
|
||||
"todo_header": "Необходимый пакет",
|
||||
"todo_plan": [
|
||||
{"number": 1, "text": "Паспорт: страница с фото, действующая виза или иной документ на право пребывания."},
|
||||
{"number": 2, "text": "Миграционная карта с отметкой о въезде (обе стороны)."},
|
||||
{"number": 3, "text": "Документ о месте пребывания: договор найма, свидетельство собственности или письмо принимающей стороны."},
|
||||
{"number": 4, "text": "При трудовой деятельности — копия патента или разрешения на работу (если применимо)."},
|
||||
{"number": 5, "text": "Нажмите «Отправить документы», когда все файлы приложены."}
|
||||
],
|
||||
"send_documents": true
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
if [[ -n "${CONTRACT_KEY}" && -n "${RESULTS_KEY}" ]]; then
|
||||
write_payload docs_ready <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "docs_ready",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-docs_ready",
|
||||
"notification_datetime": "${DT_DOCS_READY}",
|
||||
"header": "Миграционные документы готовы",
|
||||
"text": "Уведомление о постановке на учёт и справка о соблюдении требований закона сформированы — скачайте в деталях.",
|
||||
"details": {
|
||||
"details_header": "Документы по миграционному учёту",
|
||||
"details_text": "Документы подготовлены на основании переданных вами данных и проверены специалистом. Сохраните копии на устройство — они могут понадобиться при проверке или продлении статуса пребывания.\n\nСсылки на скачивание действуют ограниченное время. После первого скачивания карточка скроется с главной, но останется в Центре уведомлений до нажатия «Понятно».",
|
||||
"documents": [
|
||||
{
|
||||
"object_key": "${CONTRACT_KEY}",
|
||||
"title": "${CONTRACT_TITLE}",
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": ${CONTRACT_SIZE},
|
||||
"checksum_sha256": "${CONTRACT_SHA256}"
|
||||
},
|
||||
{
|
||||
"object_key": "${RESULTS_KEY}",
|
||||
"title": "${RESULTS_TITLE}",
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": ${RESULTS_SIZE},
|
||||
"checksum_sha256": "${RESULTS_SHA256}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON
|
||||
fi
|
||||
|
||||
write_payload status_changed <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "status_changed",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-status_changed",
|
||||
"notification_datetime": "${DT_STATUS}",
|
||||
"header": "Статус миграционной заявки обновлён",
|
||||
"text": "Заявка №МГ-8841 переведена на этап «Проверка комплектности документов».",
|
||||
"details": {
|
||||
"details_header": "Ход рассмотрения заявки №МГ-8841",
|
||||
"details_text": "27.07.2026 в 11:42 специалист принял ваш пакет документов в работу. Сейчас выполняется проверка на соответствие требованиям миграционного законодательства РФ: сроки пребывания, адрес учёта, основания для трудовой деятельности.\n\nОжидаемое время на этом этапе: 1–2 рабочих дня. При выявлении недостающих документов вы получите отдельное уведомление с перечнем. При успешной проверке будет сформировано уведомление о постановке на учёт.",
|
||||
"todo_header": "Этапы обработки",
|
||||
"todo_plan": [
|
||||
{"number": 1, "text": "Документы получены — выполнено 25.07.2026"},
|
||||
{"number": 2, "text": "Первичная верификация — выполнено 27.07.2026"},
|
||||
{"number": 3, "text": "Проверка комплектности и сроков — в работе"},
|
||||
{"number": 4, "text": "Формирование уведомления / рекомендаций — ожидает"}
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
write_payload reminder <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "reminder",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-reminder",
|
||||
"notification_datetime": "${DT_REMINDER}",
|
||||
"header": "Напоминание: продление патента",
|
||||
"text": "28 июля истекает срок действия патента — подайте заявление на продление заранее.",
|
||||
"date_expired": "${DEADLINE_REMINDER}",
|
||||
"details": {
|
||||
"deadline": "${DEADLINE_REMINDER}",
|
||||
"details_header": "Сроки продления документа на право работы",
|
||||
"details_text": "Патент на работу необходимо продлевать заблаговременно — подача заявления рекомендуется не позднее чем за 10–15 рабочих дней до даты окончания действия. Просрочка означает прекращение права на трудовую деятельность и риск штрафа по ст. 18.15 КоАП РФ.\n\nДля продления потребуются: действующий патент, чеки об оплате авансовых платежей по НДФЛ, полис ДМС, сертификат о знании русского языка (если срок действия истекает), договор найма или иной документ о месте пребывания.",
|
||||
"todo_header": "Чек-лист перед подачей",
|
||||
"todo_plan": [
|
||||
{"number": 1, "text": "Проверьте дату окончания патента в личном кабинете или на бланке документа."},
|
||||
{"number": 2, "text": "Убедитесь, что авансовые платежи по НДФЛ оплачены без просрочки."},
|
||||
{"number": 3, "text": "Подготовьте сканы документов — при необходимости загрузите через уведомление «Требуются документы»."},
|
||||
{"number": 4, "text": "При вопросах напишите оператору — укажите регион и номер патента."}
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
write_payload news <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "news",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-news",
|
||||
"notification_datetime": "${DT_NEWS}",
|
||||
"header": "Изменения в правилах миграционного учёта",
|
||||
"text": "С 1 августа 2026 уточнены сроки подачи уведомлений при смене адреса пребывания.",
|
||||
"details": {
|
||||
"details_header": "Что изменилось для иностранных граждан",
|
||||
"details_text": "С 01.08.2026 при смене адреса фактического проживания в том же субъекте РФ уведомление необходимо подать в течение 3 рабочих дней (ранее — 7). При переезде в другой регион срок остаётся 7 рабочих дней с даты регистрации по новому адресу.\n\nСервис HAN напомнит о приближающихся сроках через Центр уведомлений. Рекомендуем заранее подготовить копии договора найма и отметку о регистрации — это ускорит проверку оператором.\n\nПодробности процедуры — в чате с оператором или на официальном портале МВД России."
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
write_payload promo_personal <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "promo_personal",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-promo_personal",
|
||||
"notification_datetime": "${DT_PROMO}",
|
||||
"header": "Скидка 15% на годовое сопровождение",
|
||||
"text": "Персональное предложение: контроль сроков патента, учёта и уведомлений — до конца месяца.",
|
||||
"price": "15725.00",
|
||||
"old_price": "18500.00",
|
||||
"date_expired": "${EXPIRE_PAYMENT}",
|
||||
"chat_message_text": "Здравствуйте! Хочу подключить годовое сопровождение по соблюдению миграционного законодательства со скидкой 15%. Подскажите, что входит в пакет и как оформить."
|
||||
}
|
||||
JSON
|
||||
|
||||
write_payload ads_personal <<JSON
|
||||
{
|
||||
"user_id": "${USER_ID}",
|
||||
"notification_type": "ads_personal",
|
||||
"source": "producer_test",
|
||||
"external_id": "${RUN_ID}-ads_personal",
|
||||
"notification_datetime": "${DT_ADS}",
|
||||
"header": "Бесплатная проверка миграционного статуса",
|
||||
"text": "15 минут с экспертом: оценим риски и составим чек-лист обязательных действий.",
|
||||
"chat_message_text": "Здравствуйте! Хочу воспользоваться бесплатной проверкой миграционного статуса. Подскажите, как записаться и какие документы подготовить к консультации."
|
||||
}
|
||||
JSON
|
||||
|
||||
# --- отправка ---
|
||||
|
||||
create_notification urgent
|
||||
create_notification payment_pending
|
||||
create_notification docs_required
|
||||
if [[ -f "${PAYLOAD_DIR}/docs_ready.json" ]]; then
|
||||
create_notification docs_ready
|
||||
else
|
||||
echo
|
||||
echo "========== docs_ready — ПРОПУЩЕН =========="
|
||||
fi
|
||||
create_notification status_changed
|
||||
create_notification reminder
|
||||
create_notification news
|
||||
create_notification promo_personal
|
||||
create_notification ads_personal
|
||||
|
||||
echo
|
||||
echo "========== Готово =========="
|
||||
echo "External ID prefix: ${RUN_ID}-*"
|
||||
echo
|
||||
echo "Если видите 401: проверьте NOTIFICATIONS_TOKEN_PRODUCER_TEST в .env и перезапустите api-backend."
|
||||
echo " grep NOTIFICATIONS_TOKEN_PRODUCER_TEST .env"
|
||||
echo " docker compose --env-file .env up -d --force-recreate api-backend"
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
CONFIG_FILE=${ENV_FILE:-.env}
|
||||
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
|
||||
|
||||
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
|
||||
[ -x "$SECRETS_LAUNCHER" ] || {
|
||||
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
|
||||
exit 66
|
||||
}
|
||||
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
|
||||
fi
|
||||
|
||||
./scripts/validate-env "$CONFIG_FILE" \
|
||||
--runtime-manifest "$HAN_RUNTIME_SECRET_MANIFEST"
|
||||
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm seed-settings
|
||||
echo "Seed and mandatory-settings validation completed."
|
||||
@@ -0,0 +1,591 @@
|
||||
#!/usr/bin/env bash
|
||||
# Первичная подготовка Ubuntu 24.04 для HAN Chat.
|
||||
#
|
||||
# Скрипт настраивает только VM: пользователя развертывания, базовые пакеты,
|
||||
# Docker/Compose, UFW, fail2ban, DOCKER-USER, swap и каталоги проекта.
|
||||
# PostgreSQL и S3 остаются внешними управляемыми сервисами. Скрипт не создает
|
||||
# .env, секреты, DNS, S3-бакеты, схемы БД и TLS-сертификаты.
|
||||
#
|
||||
# Запуск на свежей VM:
|
||||
# chmod +x deployment/scripts/setup-vm.sh
|
||||
# sudo deployment/scripts/setup-vm.sh
|
||||
#
|
||||
# Основные параметры:
|
||||
# DEPLOY_USER=deploy
|
||||
# DEPLOY_DIR=/opt/han-chat/backend
|
||||
# SSH_PORT=22
|
||||
# TIMEZONE=Europe/Moscow
|
||||
# SWAP_SIZE_GB=4
|
||||
# EXTERNAL_IF=ens3
|
||||
# PUBLIC_DOCKER_PORTS=80,443
|
||||
# COPY_SSH_KEYS=true
|
||||
# HARDEN_SSH=false
|
||||
# LOCK_ACCOUNT_PASSWORDS=true
|
||||
# HSTS_MAX_AGE_SECONDS=31536000
|
||||
# RESET_UFW=false
|
||||
# SKIP_APT_UPGRADE=false
|
||||
#
|
||||
# Парольный SSH-вход, X11 forwarding и локальные пароли root/deploy отключаются
|
||||
# по умолчанию после проверки authorized_keys. HARDEN_SSH=true дополнительно
|
||||
# запрещает прямой root-вход и SSH TCP forwarding.
|
||||
|
||||
set -Eeuo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
DEPLOY_USER="${DEPLOY_USER:-deploy}"
|
||||
DEPLOY_DIR="${DEPLOY_DIR:-/opt/han-chat/backend}"
|
||||
SSH_PORT="${SSH_PORT:-22}"
|
||||
TIMEZONE="${TIMEZONE:-Europe/Moscow}"
|
||||
SWAP_SIZE_GB="${SWAP_SIZE_GB:-4}"
|
||||
EXTERNAL_IF="${EXTERNAL_IF:-}"
|
||||
PUBLIC_DOCKER_PORTS="${PUBLIC_DOCKER_PORTS:-80,443}"
|
||||
COPY_SSH_KEYS="${COPY_SSH_KEYS:-true}"
|
||||
HARDEN_SSH="${HARDEN_SSH:-false}"
|
||||
LOCK_ACCOUNT_PASSWORDS="${LOCK_ACCOUNT_PASSWORDS:-true}"
|
||||
HSTS_MAX_AGE_SECONDS="${HSTS_MAX_AGE_SECONDS:-31536000}"
|
||||
RESET_UFW="${RESET_UFW:-false}"
|
||||
SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}"
|
||||
LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm-setup.log}"
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
step() {
|
||||
log ""
|
||||
log "==> $*"
|
||||
}
|
||||
|
||||
die() {
|
||||
log "ОШИБКА: $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
on_error() {
|
||||
local exit_code=$?
|
||||
log "ОШИБКА: команда завершилась с кодом ${exit_code}, строка ${BASH_LINENO[0]}"
|
||||
exit "$exit_code"
|
||||
}
|
||||
trap on_error ERR
|
||||
|
||||
require_root() {
|
||||
[[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Запустите скрипт через sudo"
|
||||
}
|
||||
|
||||
validate_parameters() {
|
||||
[[ "$DEPLOY_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] || die "Некорректный DEPLOY_USER"
|
||||
[[ "$DEPLOY_DIR" == /* ]] || die "DEPLOY_DIR должен быть абсолютным путем"
|
||||
[[ "$SSH_PORT" =~ ^[0-9]+$ ]] || die "SSH_PORT должен быть числом"
|
||||
((SSH_PORT >= 1 && SSH_PORT <= 65535)) || die "SSH_PORT вне диапазона"
|
||||
[[ "$SWAP_SIZE_GB" =~ ^[0-9]+$ ]] || die "SWAP_SIZE_GB должен быть целым числом"
|
||||
[[ "$HSTS_MAX_AGE_SECONDS" =~ ^[0-9]+$ ]] \
|
||||
|| die "HSTS_MAX_AGE_SECONDS должен быть целым числом"
|
||||
((HSTS_MAX_AGE_SECONDS >= 31536000)) \
|
||||
|| die "HSTS_MAX_AGE_SECONDS должен быть не меньше 31536000"
|
||||
[[ "$PUBLIC_DOCKER_PORTS" =~ ^[0-9]+(,[0-9]+)*$ ]] \
|
||||
|| die "PUBLIC_DOCKER_PORTS должен иметь вид 80,443"
|
||||
}
|
||||
|
||||
check_os() {
|
||||
step "Проверка операционной системы"
|
||||
[[ -r /etc/os-release ]] || die "Не найден /etc/os-release"
|
||||
# shellcheck disable=SC1091
|
||||
source /etc/os-release
|
||||
[[ "${ID:-}" == "ubuntu" ]] || die "Поддерживается только Ubuntu"
|
||||
local major="${VERSION_ID%%.*}"
|
||||
((major >= 24)) || die "Требуется Ubuntu 24.04 или новее"
|
||||
log "Обнаружена ${PRETTY_NAME}"
|
||||
}
|
||||
|
||||
update_system() {
|
||||
step "Обновление системы и установка пакетов"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
if [[ "$SKIP_APT_UPGRADE" != "true" ]]; then
|
||||
apt-get dist-upgrade -y
|
||||
fi
|
||||
apt-get install -y \
|
||||
ca-certificates \
|
||||
curl \
|
||||
dos2unix \
|
||||
fail2ban \
|
||||
git \
|
||||
gnupg \
|
||||
iptables \
|
||||
jq \
|
||||
logrotate \
|
||||
netcat-openbsd \
|
||||
openssl \
|
||||
python3 \
|
||||
python3-venv \
|
||||
rsync \
|
||||
unattended-upgrades \
|
||||
ufw
|
||||
apt-get autoremove -y
|
||||
}
|
||||
|
||||
configure_time() {
|
||||
step "Настройка времени"
|
||||
timedatectl set-timezone "$TIMEZONE"
|
||||
timedatectl set-ntp true
|
||||
}
|
||||
|
||||
create_deploy_user() {
|
||||
step "Пользователь развертывания"
|
||||
if ! id "$DEPLOY_USER" >/dev/null 2>&1; then
|
||||
useradd --create-home --shell /bin/bash "$DEPLOY_USER"
|
||||
log "Создан пользователь ${DEPLOY_USER}"
|
||||
else
|
||||
log "Пользователь ${DEPLOY_USER} уже существует"
|
||||
fi
|
||||
|
||||
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \
|
||||
"/home/${DEPLOY_USER}/.ssh"
|
||||
|
||||
local source_user="${SUDO_USER:-}"
|
||||
local source_keys=""
|
||||
local target_keys="/home/${DEPLOY_USER}/.ssh/authorized_keys"
|
||||
if [[ -n "$source_user" && "$source_user" != "root" ]]; then
|
||||
source_keys="/home/${source_user}/.ssh/authorized_keys"
|
||||
elif [[ -s /root/.ssh/authorized_keys ]]; then
|
||||
source_user="root"
|
||||
source_keys="/root/.ssh/authorized_keys"
|
||||
fi
|
||||
|
||||
if [[ "$COPY_SSH_KEYS" == "true" && ! -s "$target_keys" && -s "$source_keys" ]]; then
|
||||
install -m 600 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$source_keys" "$target_keys"
|
||||
log "SSH-ключи скопированы от ${source_user}"
|
||||
fi
|
||||
|
||||
if [[ ! -s "$target_keys" ]]; then
|
||||
log "ПРЕДУПРЕЖДЕНИЕ: у ${DEPLOY_USER} отсутствует authorized_keys"
|
||||
fi
|
||||
}
|
||||
|
||||
configure_account_passwords() {
|
||||
step "Блокировка локальных паролей привилегированных учетных записей"
|
||||
if [[ "$LOCK_ACCOUNT_PASSWORDS" != "true" ]]; then
|
||||
log "LOCK_ACCOUNT_PASSWORDS=false: локальные пароли root и ${DEPLOY_USER} не изменены"
|
||||
return
|
||||
fi
|
||||
|
||||
[[ -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \
|
||||
|| die "Нельзя заблокировать пароль ${DEPLOY_USER}: authorized_keys пользователя пуст"
|
||||
|
||||
passwd --lock root
|
||||
passwd --lock "$DEPLOY_USER"
|
||||
log "Локальные пароли root и ${DEPLOY_USER} заблокированы; вход по SSH-ключам сохранен"
|
||||
}
|
||||
|
||||
configure_layout() {
|
||||
step "Каталоги HAN Chat"
|
||||
install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$DEPLOY_DIR"
|
||||
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \
|
||||
"${DEPLOY_DIR}/secrets" \
|
||||
"${DEPLOY_DIR}/secrets/pg" \
|
||||
"${DEPLOY_DIR}/backups"
|
||||
|
||||
local env_file="${DEPLOY_DIR}/.env"
|
||||
if [[ -f "$env_file" ]]; then
|
||||
chown "$DEPLOY_USER:$DEPLOY_USER" "$env_file"
|
||||
chmod 600 "$env_file"
|
||||
fi
|
||||
}
|
||||
|
||||
configure_swap() {
|
||||
step "Настройка swap"
|
||||
if ((SWAP_SIZE_GB == 0)); then
|
||||
log "Создание swap отключено"
|
||||
return
|
||||
fi
|
||||
if swapon --show=NAME --noheadings | grep -qx '/swapfile'; then
|
||||
log "Swap уже подключен"
|
||||
return
|
||||
fi
|
||||
if [[ ! -f /swapfile ]]; then
|
||||
fallocate -l "${SWAP_SIZE_GB}G" /swapfile
|
||||
chmod 600 /swapfile
|
||||
mkswap /swapfile
|
||||
fi
|
||||
swapon /swapfile
|
||||
grep -q '^/swapfile ' /etc/fstab \
|
||||
|| printf '/swapfile none swap sw 0 0\n' >>/etc/fstab
|
||||
printf 'vm.swappiness = 10\n' >/etc/sysctl.d/99-han-chat-swappiness.conf
|
||||
sysctl --system >/dev/null
|
||||
}
|
||||
|
||||
configure_sysctl() {
|
||||
step "Настройка сетевого стека"
|
||||
cat >/etc/sysctl.d/99-han-chat-hardening.conf <<'EOF'
|
||||
net.ipv4.ip_forward = 1
|
||||
net.ipv4.tcp_syncookies = 1
|
||||
net.ipv4.conf.all.accept_redirects = 0
|
||||
net.ipv4.conf.default.accept_redirects = 0
|
||||
net.ipv4.conf.all.send_redirects = 0
|
||||
net.ipv4.conf.default.send_redirects = 0
|
||||
net.ipv4.conf.all.rp_filter = 1
|
||||
net.ipv4.conf.default.rp_filter = 1
|
||||
net.ipv4.icmp_echo_ignore_broadcasts = 1
|
||||
net.ipv4.tcp_fin_timeout = 30
|
||||
EOF
|
||||
sysctl --system >/dev/null
|
||||
}
|
||||
|
||||
install_docker() {
|
||||
step "Установка Docker Engine и Compose"
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
|
||||
| gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
# shellcheck disable=SC1091
|
||||
source /etc/os-release
|
||||
printf '%s\n' \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" \
|
||||
>/etc/apt/sources.list.d/docker.list
|
||||
apt-get update
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
fi
|
||||
|
||||
install -d -m 755 /etc/docker
|
||||
cat >/etc/docker/daemon.json <<'EOF'
|
||||
{
|
||||
"live-restore": true,
|
||||
"log-driver": "json-file",
|
||||
"log-opts": {
|
||||
"max-size": "50m",
|
||||
"max-file": "5"
|
||||
},
|
||||
"userland-proxy": false
|
||||
}
|
||||
EOF
|
||||
systemctl enable --now docker
|
||||
systemctl restart docker
|
||||
usermod -aG docker "$DEPLOY_USER"
|
||||
|
||||
docker compose version >/dev/null \
|
||||
|| die "Docker Compose plugin не установлен"
|
||||
log "$(docker --version)"
|
||||
log "$(docker compose version)"
|
||||
}
|
||||
|
||||
configure_ufw() {
|
||||
step "Настройка UFW"
|
||||
if [[ "$RESET_UFW" == "true" ]]; then
|
||||
ufw --force reset
|
||||
fi
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw allow "${SSH_PORT}/tcp" comment 'HAN Chat SSH'
|
||||
ufw limit "${SSH_PORT}/tcp" comment 'HAN Chat SSH rate limit'
|
||||
ufw allow 80/tcp comment 'HAN Chat HTTP'
|
||||
ufw allow 443/tcp comment 'HAN Chat HTTPS'
|
||||
ufw logging medium
|
||||
ufw --force enable
|
||||
}
|
||||
|
||||
configure_fail2ban() {
|
||||
step "Настройка fail2ban для SSH"
|
||||
cat >/etc/fail2ban/jail.d/han-chat.local <<EOF
|
||||
[DEFAULT]
|
||||
bantime = 2h
|
||||
findtime = 10m
|
||||
maxretry = 5
|
||||
backend = systemd
|
||||
banaction = ufw
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = ${SSH_PORT}
|
||||
maxretry = 3
|
||||
EOF
|
||||
systemctl enable --now fail2ban
|
||||
systemctl restart fail2ban
|
||||
}
|
||||
|
||||
configure_unattended_upgrades() {
|
||||
step "Автоматические обновления безопасности"
|
||||
cat >/etc/apt/apt.conf.d/51han-chat-unattended <<'EOF'
|
||||
Unattended-Upgrade::Remove-Unused-Dependencies "true";
|
||||
Unattended-Upgrade::Automatic-Reboot "false";
|
||||
EOF
|
||||
dpkg-reconfigure -f noninteractive unattended-upgrades
|
||||
systemctl enable --now unattended-upgrades
|
||||
}
|
||||
|
||||
configure_docker_firewall() {
|
||||
step "Фильтрация опубликованных Docker-портов"
|
||||
cat >/etc/default/han-chat-docker-firewall <<EOF
|
||||
EXTERNAL_IF=${EXTERNAL_IF}
|
||||
PUBLIC_DOCKER_PORTS=${PUBLIC_DOCKER_PORTS}
|
||||
EOF
|
||||
|
||||
cat >/usr/local/sbin/han-chat-docker-firewall <<'FIREWALL'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
# shellcheck disable=SC1091
|
||||
source /etc/default/han-chat-docker-firewall
|
||||
|
||||
external_if="${EXTERNAL_IF:-}"
|
||||
if [[ -z "$external_if" ]]; then
|
||||
external_if="$(ip -4 route show default | awk '{print $5; exit}')"
|
||||
fi
|
||||
[[ -n "$external_if" ]] || {
|
||||
echo "Не удалось определить внешний интерфейс; задайте EXTERNAL_IF" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
iptables -N HAN-CHAT-DOCKER 2>/dev/null || true
|
||||
iptables -F HAN-CHAT-DOCKER
|
||||
|
||||
iptables -A HAN-CHAT-DOCKER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
|
||||
iptables -A HAN-CHAT-DOCKER -i lo -j RETURN
|
||||
|
||||
IFS=',' read -ra ports <<<"$PUBLIC_DOCKER_PORTS"
|
||||
for port in "${ports[@]}"; do
|
||||
[[ "$port" =~ ^[0-9]+$ ]] || {
|
||||
echo "Некорректный порт: $port" >&2
|
||||
exit 1
|
||||
}
|
||||
iptables -A HAN-CHAT-DOCKER -i "$external_if" -p tcp --dport "$port" -j RETURN
|
||||
done
|
||||
|
||||
# Блокируется только новый входящий трафик с внешнего интерфейса в Docker bridge.
|
||||
# Исходящий и межконтейнерный трафик этой цепочкой не затрагивается.
|
||||
iptables -A HAN-CHAT-DOCKER -i "$external_if" -o docker+ -j DROP
|
||||
iptables -A HAN-CHAT-DOCKER -i "$external_if" -o br+ -j DROP
|
||||
iptables -A HAN-CHAT-DOCKER -j RETURN
|
||||
|
||||
while iptables -C DOCKER-USER -j HAN-CHAT-DOCKER 2>/dev/null; do
|
||||
iptables -D DOCKER-USER -j HAN-CHAT-DOCKER
|
||||
done
|
||||
iptables -I DOCKER-USER 1 -j HAN-CHAT-DOCKER
|
||||
FIREWALL
|
||||
chmod 750 /usr/local/sbin/han-chat-docker-firewall
|
||||
|
||||
cat >/etc/systemd/system/han-chat-docker-firewall.service <<'EOF'
|
||||
[Unit]
|
||||
Description=HAN Chat firewall for Docker published ports
|
||||
After=docker.service network-online.target
|
||||
Wants=docker.service network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/han-chat-docker-firewall
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
install -d -m 755 /etc/systemd/system/docker.service.d
|
||||
cat >/etc/systemd/system/docker.service.d/han-chat-firewall.conf <<'EOF'
|
||||
[Service]
|
||||
ExecStartPost=-/usr/local/sbin/han-chat-docker-firewall
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now han-chat-docker-firewall.service
|
||||
}
|
||||
|
||||
configure_ssh() {
|
||||
step "Настройка SSH"
|
||||
[[ -s /root/.ssh/authorized_keys || -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \
|
||||
|| die "Нельзя отключить парольный SSH-вход: не найден ни один authorized_keys"
|
||||
|
||||
cat >/etc/ssh/sshd_config.d/00-han-chat.conf <<EOF
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
X11Forwarding no
|
||||
MaxAuthTries 3
|
||||
ClientAliveInterval 120
|
||||
ClientAliveCountMax 2
|
||||
Port ${SSH_PORT}
|
||||
EOF
|
||||
if [[ "$HARDEN_SSH" == "true" ]]; then
|
||||
cat >>/etc/ssh/sshd_config.d/00-han-chat.conf <<'EOF'
|
||||
PermitRootLogin no
|
||||
AllowTcpForwarding no
|
||||
EOF
|
||||
log "Расширенный SSH hardening включен: root-вход и TCP forwarding запрещены"
|
||||
else
|
||||
log "Базовый SSH hardening включен; root-вход и TCP forwarding не изменены"
|
||||
fi
|
||||
|
||||
rm -f /etc/ssh/sshd_config.d/99-han-chat.conf
|
||||
sshd -t || die "Проверка конфигурации sshd не пройдена"
|
||||
systemctl reload ssh
|
||||
}
|
||||
|
||||
configure_application_security() {
|
||||
step "Безопасные HTTP-заголовки приложения"
|
||||
local env_file="${DEPLOY_DIR}/.env"
|
||||
|
||||
if [[ -f "$env_file" ]]; then
|
||||
if grep -q '^NGINX_HSTS_MAX_AGE=' "$env_file"; then
|
||||
sed -i "s/^NGINX_HSTS_MAX_AGE=.*/NGINX_HSTS_MAX_AGE=${HSTS_MAX_AGE_SECONDS}/" "$env_file"
|
||||
else
|
||||
printf '\nNGINX_HSTS_MAX_AGE=%s\n' "$HSTS_MAX_AGE_SECONDS" >>"$env_file"
|
||||
fi
|
||||
chown "$DEPLOY_USER:$DEPLOY_USER" "$env_file"
|
||||
chmod 600 "$env_file"
|
||||
log "HSTS настроен на ${HSTS_MAX_AGE_SECONDS} секунд в ${env_file}"
|
||||
else
|
||||
log "Проект еще не настроен: HSTS будет взят из безопасного значения Compose по умолчанию"
|
||||
fi
|
||||
}
|
||||
|
||||
install_secret_loader_if_possible() {
|
||||
step "Загрузчик секретов"
|
||||
local source_dir="${DEPLOY_DIR}/deployment/secrets"
|
||||
if [[ ! -f "${source_dir}/secrets_loader.py" || ! -f "${source_dir}/han-secrets" ]]; then
|
||||
log "Проект еще не скопирован: загрузчик секретов будет установлен при повторном запуске"
|
||||
return
|
||||
fi
|
||||
chmod 0750 "${source_dir}/han-secrets" "${source_dir}/han-compose"
|
||||
|
||||
install -d -m 0700 -o root -g root \
|
||||
/etc/han \
|
||||
/etc/han/secrets \
|
||||
/etc/han/credentials
|
||||
install -d -m 0755 -o root -g root \
|
||||
/usr/local/lib/han-secrets \
|
||||
/usr/local/share/doc/han-secrets
|
||||
install -m 0750 -o root -g root \
|
||||
"${source_dir}/secrets_loader.py" \
|
||||
/usr/local/lib/han-secrets/secrets_loader.py
|
||||
install -m 0750 -o root -g root \
|
||||
"${source_dir}/han-secrets" \
|
||||
/usr/local/lib/han-secrets/han-secrets
|
||||
install -m 0750 -o root -g root \
|
||||
"${source_dir}/han-compose" \
|
||||
/usr/local/bin/han-compose
|
||||
install -m 0644 -o root -g root \
|
||||
"${source_dir}/han-secrets@.service" \
|
||||
/etc/systemd/system/han-secrets@.service
|
||||
install -m 0644 -o root -g root \
|
||||
"${source_dir}/SELECTEL_RUNBOOK.ru.md" \
|
||||
/usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md
|
||||
if [[ ! -e /etc/han/secrets/production.selectel.json.example ]]; then
|
||||
install -m 0600 -o root -g root \
|
||||
"${source_dir}/config.example.json" \
|
||||
/etc/han/secrets/production.selectel.json.example
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
log "Загрузчик установлен, но не включен: сначала выполните SELECTEL_RUNBOOK.ru.md"
|
||||
}
|
||||
|
||||
install_ssl_timer_if_possible() {
|
||||
step "Таймер продления TLS"
|
||||
local renew_script="${DEPLOY_DIR}/deployment/scripts/ssl-renew.sh"
|
||||
if [[ ! -x "$renew_script" ]]; then
|
||||
log "Проект еще не скопирован: таймер TLS будет установлен при повторном запуске"
|
||||
return
|
||||
fi
|
||||
|
||||
cat >/etc/systemd/system/han-chat-ssl-renew.service <<EOF
|
||||
[Unit]
|
||||
Description=Renew HAN Chat TLS certificate
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=${DEPLOY_USER}
|
||||
WorkingDirectory=${DEPLOY_DIR}
|
||||
ExecStart=${renew_script}
|
||||
EOF
|
||||
|
||||
cat >/etc/systemd/system/han-chat-ssl-renew.timer <<'EOF'
|
||||
[Unit]
|
||||
Description=Run HAN Chat TLS renewal twice daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03,15:20:00
|
||||
RandomizedDelaySec=30m
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now han-chat-ssl-renew.timer
|
||||
}
|
||||
|
||||
verify() {
|
||||
step "Проверка результата"
|
||||
local failed=0
|
||||
systemctl is-active --quiet docker || { log "FAIL: Docker не активен"; failed=1; }
|
||||
systemctl is-active --quiet fail2ban || { log "FAIL: fail2ban не активен"; failed=1; }
|
||||
ufw status | grep -q 'Status: active' || { log "FAIL: UFW не активен"; failed=1; }
|
||||
iptables -C DOCKER-USER -j HAN-CHAT-DOCKER 2>/dev/null \
|
||||
|| { log "FAIL: цепочка HAN-CHAT-DOCKER не подключена"; failed=1; }
|
||||
docker compose version >/dev/null || { log "FAIL: Compose недоступен"; failed=1; }
|
||||
[[ -d "$DEPLOY_DIR" ]] || { log "FAIL: отсутствует ${DEPLOY_DIR}"; failed=1; }
|
||||
((failed == 0)) || die "Базовая проверка VM не пройдена"
|
||||
log "Базовая проверка VM пройдена"
|
||||
}
|
||||
|
||||
summary() {
|
||||
step "Настройка VM завершена"
|
||||
cat <<EOF | tee -a "$LOG_FILE"
|
||||
|
||||
Пользователь развертывания: ${DEPLOY_USER}
|
||||
Каталог Compose: ${DEPLOY_DIR}
|
||||
Открытые порты: ${SSH_PORT}, 80, 443
|
||||
Парольный SSH/X11: отключены
|
||||
Локальные пароли: ${LOCK_ACCOUNT_PASSWORDS}
|
||||
HSTS max-age: ${HSTS_MAX_AGE_SECONDS}
|
||||
Лог настройки: ${LOG_FILE}
|
||||
|
||||
Следующие действия:
|
||||
1. Проверьте вход в новой SSH-сессии:
|
||||
ssh ${DEPLOY_USER}@<VM_IP>
|
||||
2. Скопируйте содержимое codebase/backend в:
|
||||
${DEPLOY_DIR}
|
||||
3. Поместите CA PostgreSQL:
|
||||
${DEPLOY_DIR}/secrets/pg/ca.pem
|
||||
4. Создайте только несекретный config:
|
||||
cd ${DEPLOY_DIR}
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
./scripts/validate-env .env
|
||||
5. Настройте Selectel, encrypted bootstrap credential и fallback map:
|
||||
deployment/secrets/SELECTEL_RUNBOOK.ru.md
|
||||
6. Выполняйте Compose только через:
|
||||
sudo deployment/secrets/han-compose <command>
|
||||
7. Продолжите с Gate 7 в:
|
||||
deployment/RUNBOOK.ru.md
|
||||
8. После копирования проекта повторно запустите этот скрипт для установки unit-файлов.
|
||||
|
||||
Важно: членство в группе docker начнет действовать после нового входа в систему.
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
require_root
|
||||
install -d -m 755 "$(dirname "$LOG_FILE")"
|
||||
touch "$LOG_FILE"
|
||||
chmod 600 "$LOG_FILE"
|
||||
validate_parameters
|
||||
check_os
|
||||
update_system
|
||||
configure_time
|
||||
create_deploy_user
|
||||
configure_account_passwords
|
||||
configure_layout
|
||||
configure_swap
|
||||
configure_sysctl
|
||||
install_docker
|
||||
configure_ufw
|
||||
configure_fail2ban
|
||||
configure_unattended_upgrades
|
||||
configure_docker_firewall
|
||||
configure_ssh
|
||||
configure_application_security
|
||||
install_secret_loader_if_possible
|
||||
install_ssl_timer_if_possible
|
||||
verify
|
||||
summary
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
CONFIG_FILE=${CONFIG_FILE:-.env}
|
||||
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
|
||||
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
|
||||
[ -x "$SECRETS_LAUNCHER" ] || {
|
||||
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
|
||||
exit 66
|
||||
}
|
||||
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
|
||||
fi
|
||||
if [ -n "${HAN_RUNTIME_SECRET_MANIFEST:-}" ]; then
|
||||
./scripts/validate-env "$CONFIG_FILE" --runtime-manifest "$HAN_RUNTIME_SECRET_MANIFEST"
|
||||
else
|
||||
./scripts/validate-env "$CONFIG_FILE" --runtime-env
|
||||
fi
|
||||
|
||||
env_value() {
|
||||
python3 - "$CONFIG_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
|
||||
|
||||
http_code=$(curl -sS -o /dev/null -w '%{http_code}' "http://${PUBLIC_HOST}/")
|
||||
[ "$http_code" = "308" ] || { echo "Expected HTTP 308, got $http_code" >&2; exit 1; }
|
||||
curl -fsS "${PUBLIC_WEB_URL}/api/v1/public/app-config" -o "$tmp/app-config.json"
|
||||
curl -fsS "${PUBLIC_WEB_URL}/api/v1/public/content" -o "$tmp/content.json"
|
||||
curl -fsS "${PUBLIC_WEB_URL}/auth/realms/${KEYCLOAK_REALM}/.well-known/openid-configuration" -o "$tmp/oidc.json"
|
||||
|
||||
internal_code=$(curl -sS -o /dev/null -w '%{http_code}' "${PUBLIC_WEB_URL}/internal/safety/v1/messages/check")
|
||||
[ "$internal_code" = "404" ] || { echo "Public /internal returned $internal_code, expected 404" >&2; exit 1; }
|
||||
sms_internal_code=$(curl -sS -o /dev/null -w '%{http_code}' "${PUBLIC_WEB_URL}/internal/sms/v1/messages/00000000-0000-0000-0000-000000000000")
|
||||
[ "$sms_internal_code" = "404" ] || { echo "Public SMS internal API returned $sms_internal_code, expected 404" >&2; exit 1; }
|
||||
sms_callback_code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H 'Content-Type: application/json' --data '[]' \
|
||||
"${PUBLIC_WEB_URL}/callbacks/idgtl/sms")
|
||||
[ "$sms_callback_code" = "403" ] || { echo "SMS callback without provider IP returned $sms_callback_code, expected 403" >&2; exit 1; }
|
||||
|
||||
headers=$(curl -fsSI "${PUBLIC_WEB_URL}/")
|
||||
printf '%s' "$headers" | grep -qi '^x-content-type-options: nosniff'
|
||||
printf '%s' "$headers" | grep -qi '^x-request-id:'
|
||||
printf '%s' "$headers" | grep -qi '^content-security-policy:'
|
||||
|
||||
openssl s_client -connect "${PUBLIC_HOST}:443" -servername "$PUBLIC_HOST" </dev/null 2>/dev/null \
|
||||
| openssl x509 -noout -checkend 604800
|
||||
echo "Public edge smoke passed."
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
CONFIG_FILE=${CONFIG_FILE:-.env}
|
||||
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
|
||||
|
||||
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
|
||||
[ -x "$SECRETS_LAUNCHER" ] || {
|
||||
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
|
||||
exit 66
|
||||
}
|
||||
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
|
||||
fi
|
||||
|
||||
lock=/tmp/han-chat-cert-renew.lock
|
||||
exec 9>"$lock"
|
||||
flock -n 9 || { echo '{"event":"tls.renew.skipped","reason":"lock_busy"}'; exit 0; }
|
||||
|
||||
compose() {
|
||||
docker compose --env-file "$CONFIG_FILE" "$@"
|
||||
}
|
||||
|
||||
nginx_container="$(compose ps --status running --quiet nginx)"
|
||||
if [ -z "$nginx_container" ]; then
|
||||
echo '{"event":"tls.renew.failed","reason":"nginx_not_running"}' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
compose --profile certbot run --rm certbot renew \
|
||||
--webroot -w /var/www/certbot --quiet
|
||||
compose exec -T nginx nginx -t -c /tmp/nginx.conf
|
||||
|
||||
# Сигнал отправляется PID 1 контейнера. Нельзя использовать `nginx -s reload`:
|
||||
# он ищет дефолтный /var/run/nginx.pid, тогда как рабочий PID — /tmp/nginx.pid.
|
||||
compose kill --signal HUP nginx
|
||||
|
||||
compose ps --status running --quiet nginx | awk 'NF {found=1} END {exit !found}'
|
||||
echo "{\"event\":\"tls.renew.completed\",\"timestamp\":\"$(date -u +%FT%TZ)\"}"
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
cd "$(dirname "$0")/../.."
|
||||
CONFIG_FILE="${CONFIG_FILE:-.env}"
|
||||
SECRETS_LAUNCHER="${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}"
|
||||
|
||||
if [[ "${HAN_SECRETS_ACTIVE:-0}" != 1 ]]; then
|
||||
[[ -x "$SECRETS_LAUNCHER" ]] || {
|
||||
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
|
||||
exit 66
|
||||
}
|
||||
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
|
||||
fi
|
||||
|
||||
compose() { docker compose --env-file "$CONFIG_FILE" "$@"; }
|
||||
|
||||
NETWORK="${OBSERVABILITY_NETWORK:-han-chat-observability}"
|
||||
COLLECTOR_SERVICE="${COLLECTOR_SERVICE:-otel-collector}"
|
||||
errors=0
|
||||
|
||||
ok() { printf 'OK %s\n' "$*"; }
|
||||
fail() { printf 'FAIL %s\n' "$*" >&2; errors=$((errors + 1)); }
|
||||
|
||||
collector_id="$(compose ps -q "$COLLECTOR_SERVICE" 2>/dev/null || true)"
|
||||
if [[ -n "$collector_id" ]] &&
|
||||
[[ "$(docker inspect --format '{{.State.Status}}' "$collector_id")" == running ]]; then
|
||||
ok "Collector service is running"
|
||||
else
|
||||
fail "Collector service '$COLLECTOR_SERVICE' is not running"
|
||||
fi
|
||||
|
||||
for service in sms-service sms-worker; do
|
||||
if compose exec -T "$service" python - <<'PY' >/dev/null 2>&1
|
||||
import os
|
||||
import urllib.request
|
||||
port = os.environ.get("SMS_METRICS_PORT", "9464") if "worker" in os.environ.get("OTEL_SERVICE_NAME", "") else "8080"
|
||||
urllib.request.urlopen(f"http://127.0.0.1:{port}/metrics", timeout=3).read(1024)
|
||||
PY
|
||||
then
|
||||
ok "${service} metrics endpoint"
|
||||
else
|
||||
fail "${service} metrics endpoint"
|
||||
fi
|
||||
done
|
||||
|
||||
docker run --rm --network "$NETWORK" \
|
||||
ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest \
|
||||
traces --otlp-endpoint otel-collector:4317 --otlp-insecure \
|
||||
--service han-chat-e2e-canary --traces 100 --rate 20 >/dev/null \
|
||||
&& ok "100 canary traces submitted" \
|
||||
|| fail "telemetrygen failed"
|
||||
|
||||
bad_logs="$(
|
||||
compose logs --since=10m "$COLLECTOR_SERVICE" 2>&1 |
|
||||
grep -Ei 'queue is full|connection refused|tls:|Unauthenticated|Permanent error' || true
|
||||
)"
|
||||
if [[ -z "$bad_logs" ]]; then
|
||||
ok "No exporter/queue errors in last 10 minutes"
|
||||
else
|
||||
fail "Collector reports exporter/queue errors"
|
||||
printf '%s\n' "$bad_logs" >&2
|
||||
fi
|
||||
|
||||
if ((errors)); then
|
||||
printf 'Observability verification failed: %d check(s)\n' "$errors" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat <<'EOF'
|
||||
Локальный канал исправен. В SigNoz проверьте за последние 15 минут:
|
||||
service.name = han-chat-e2e-canary
|
||||
service.namespace = han-chat
|
||||
Затем выполните synthetic API request и проверьте общий trace между
|
||||
api-backend и dependency spans, service.version и deployment.environment.
|
||||
EOF
|
||||
@@ -0,0 +1,206 @@
|
||||
# Selectel Secrets Manager для HAN Chat
|
||||
|
||||
## Модель
|
||||
|
||||
`han-secrets` читает только `SECRETS_SOURCE=selectel|file` из обычного `.env`,
|
||||
выбирает соответствующую root-only JSON-карту и вызывает `secrets_loader.py`.
|
||||
Загрузчик получает project-scoped IAM token, читает объявленные секреты и
|
||||
создаёт в `/run/han-chat/secrets`:
|
||||
|
||||
- отдельные файлы с каноническими именами для Compose secrets;
|
||||
- узкие service dotenv-файлы для диагностики состава без вывода значений;
|
||||
- `manifest` вида `NAME=/absolute/path`, используемый валидатором.
|
||||
|
||||
Каталог `/run` находится в tmpfs и имеет режим `0700`. Канонические файлы имеют
|
||||
`0444`: локальные пользователи не могут пройти через root-only каталог, а
|
||||
не-root UID контейнера может прочитать только явно смонтированный Compose
|
||||
secret. Значения не передаются через Docker Config.Env, argv, общий `.env` или
|
||||
логи. После полного root/docker-компромисса runtime-значения извлекаемы — это
|
||||
ограничение модели, а не гарантия Secret Manager.
|
||||
|
||||
Сбой Selectel никогда автоматически не включает file fallback. Уже работающие
|
||||
контейнеры продолжают использовать текущие значения; новый sync завершается
|
||||
fail-closed.
|
||||
|
||||
## 1. Ресурсы Selectel
|
||||
|
||||
1. Создайте отдельный проект `han-chat-secrets-prod`.
|
||||
2. Создайте сервисного пользователя `han-chat-secrets-reader`.
|
||||
3. Назначьте ему `member` только в этом проекте. Не выдавайте account scope,
|
||||
`iam.admin` и доступ к другим production-ресурсам. Если Selectel добавит
|
||||
отдельную read-only роль Secrets Manager, замените `member` на неё.
|
||||
4. Ограничьте обращения к `api.selectel.ru` исходящим IP ВМ, если функция
|
||||
доступна в аккаунте.
|
||||
5. Включите экспорт audit logs. Контролируйте события `secrets.secret*` и
|
||||
`secrets.secret_version*`; alert на delete, смену current version вне окна,
|
||||
массовые чтения и обращения не от штатного пользователя/IP.
|
||||
6. Выполните canary и убедитесь, что provider audit содержит metadata операции,
|
||||
но не value, Base64 payload, IAM token или response body.
|
||||
|
||||
Secrets Manager принимает project-scoped IAM token в `X-Auth-Token`. Token
|
||||
живёт до 24 часов, но загрузчик использует его только в памяти одного запуска.
|
||||
TLS и redirect policy отключать нельзя.
|
||||
|
||||
## 2. Каталог секретов
|
||||
|
||||
Скопируйте `config.example.json` в
|
||||
`/etc/han/secrets/production-like.selectel.json` и замените account, username,
|
||||
project, region и `remote`. Каноническое имя слева обязано совпадать с
|
||||
Compose/validator; `remote` — неизменяемый ключ в Selectel.
|
||||
|
||||
Используйте консервативные provider keys с дефисами, например:
|
||||
|
||||
- `han-chat-prod-pg-han-app-dsn`, `han-chat-prod-pg-bitrix-local-dsn`,
|
||||
`han-chat-prod-pg-bitrix-sync-dsn`, `han-chat-prod-pg-sms-dsn`,
|
||||
`han-chat-prod-pg-keycloak-password`, `han-chat-prod-pg-backup-dsn`;
|
||||
- `han-chat-prod-redis-api-password`, `han-chat-prod-redis-safety-password`,
|
||||
`han-chat-prod-redis-health-password`, а также три credential-bearing URL;
|
||||
- `han-chat-prod-message-safety-token`, `han-chat-prod-bitrix-internal-token`,
|
||||
`han-chat-prod-bitrix-forward-token`, `han-chat-prod-bitrix-sync-token`,
|
||||
`han-chat-prod-keycloak-settings-token`, `han-chat-prod-sms-service-token`;
|
||||
- Keycloak bootstrap password, OTP HMAC и mock code только для среды, где mock
|
||||
действительно включён;
|
||||
- Bitrix client secret, application token и token encryption key;
|
||||
- пары S3 app access/secret key;
|
||||
- i-Digital API key и callback username/password.
|
||||
|
||||
Одинаковые пары env (`BITRIX_LOCAL_APP_INTERNAL_TOKEN` /
|
||||
`BITRIX_INTERNAL_API_TOKEN`, `BITRIX_API_FORWARD_TOKEN` /
|
||||
`BITRIX_API_INBOX_TOKEN`, `SMS_SERVICE_TOKEN` /
|
||||
`KEYCLOAK_SMS_SERVICE_TOKEN`) должны ссылаться на один `remote`.
|
||||
|
||||
`literal: ""` разрешён только для заведомо пустого optional-параметра, например
|
||||
OTLP auth при self-hosted SigNoz или выключенного CAPTCHA server key. Секреты
|
||||
не записывайте в JSON. Не заводите planned/unused Message Safety DB, quarantine
|
||||
S3 и Bitrix sync credentials до появления потребляющего кода.
|
||||
|
||||
Загружайте значения в Selectel через скрытый prompt/stdin. Не передавайте value
|
||||
позиционным аргументом CLI и не включайте `set -x`/`curl -v`.
|
||||
|
||||
## 3. Установка на ВМ
|
||||
|
||||
Повторный запуск `deployment/scripts/setup-vm.sh` после копирования проекта
|
||||
устанавливает loader, launcher, systemd template и этот runbook. Вручную:
|
||||
|
||||
```sh
|
||||
sudo install -d -m 0700 /etc/han/secrets /etc/han/credentials
|
||||
sudo install -d -m 0755 /usr/local/lib/han-secrets
|
||||
sudo install -m 0750 secrets_loader.py han-secrets /usr/local/lib/han-secrets/
|
||||
sudo install -m 0750 han-compose /usr/local/bin/han-compose
|
||||
sudo install -m 0644 han-secrets@.service /etc/systemd/system/
|
||||
sudo install -m 0600 config.example.json \
|
||||
/etc/han/secrets/production-like.selectel.json
|
||||
```
|
||||
|
||||
Обычный `/opt/han-chat/backend/.env` содержит только несекретные параметры.
|
||||
Штатный режим:
|
||||
|
||||
```dotenv
|
||||
SECRETS_SOURCE=selectel
|
||||
APP_ENV=production-like
|
||||
```
|
||||
|
||||
## 4. Bootstrap credential
|
||||
|
||||
Не храните пароль service user в `.env` или JSON. На целевой ВМ:
|
||||
|
||||
```sh
|
||||
sudo systemd-creds encrypt --name=selectel-service-user-password - \
|
||||
/etc/han/credentials/production.selectel-password.cred
|
||||
sudo chmod 0600 /etc/han/credentials/production.selectel-password.cred
|
||||
```
|
||||
|
||||
Введите пароль через интерактивный stdin. Unit передаёт расшифрованный файл
|
||||
через приватный `$CREDENTIALS_DIRECTORY`. Root всё равно может его извлечь;
|
||||
после инцидента credential и все доступные ему секреты необходимо ротировать.
|
||||
|
||||
## 5. Проверка и запуск
|
||||
|
||||
Все команды, которым нужны Compose secrets, запускайте от root через wrapper:
|
||||
|
||||
```sh
|
||||
cd /opt/han-chat/backend
|
||||
sudo ./scripts/validate-env .env
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable han-secrets@production.service
|
||||
sudo systemctl restart han-secrets@production.service
|
||||
sudo ./scripts/validate-env .env \
|
||||
--runtime-manifest /run/han-chat/secrets/manifest
|
||||
sudo deployment/secrets/han-compose config --quiet
|
||||
sudo deployment/secrets/han-compose up -d --wait
|
||||
```
|
||||
|
||||
Selectel sync запускается именно unit-файлом: только он предоставляет
|
||||
расшифрованный bootstrap credential через `$CREDENTIALS_DIRECTORY`.
|
||||
`han-compose` и ops-скрипты используют уже синхронизированный manifest и
|
||||
отказываются работать, если `SECRETS_SOURCE`/loader config не совпадают с
|
||||
runtime state. После смены source, provider version или JSON-карты сначала
|
||||
выполняйте `systemctl restart han-secrets@production.service`.
|
||||
|
||||
Не используйте `docker compose config` без `--quiet`, `docker inspect` для
|
||||
поиска конфигурации, `env`, `strace`, core dump или debug HTTP proxy. Проверка
|
||||
приёмки должна подтвердить отсутствие canary value в `docker inspect`, stdout,
|
||||
json logs, traces и shell history.
|
||||
|
||||
На выделенной только под HAN Chat ВМ после canary и проверки file fallback
|
||||
установите fail-closed ordering:
|
||||
|
||||
```sh
|
||||
sudo install -d -m 0755 /etc/systemd/system/docker.service.d
|
||||
sudo install -m 0644 \
|
||||
deployment/secrets/docker-han-secrets.conf.example \
|
||||
/etc/systemd/system/docker.service.d/han-secrets.conf
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
После этого проведите reboot rehearsal: materializer должен завершиться до
|
||||
autorestart контейнеров. Ошибка Selectel намеренно блокирует Docker. На ВМ с
|
||||
другими workloads такой глобальный `Requires=` запрещён: нужен отдельный Docker
|
||||
daemon/VM, иначе fail-closed HAN остановит несвязанные системы.
|
||||
|
||||
## 6. Явный file fallback
|
||||
|
||||
Подготовьте отдельную карту
|
||||
`/etc/han/secrets/production-like.file.json`: скопируйте Selectel-карту,
|
||||
установите `"mode": "file"`, удалите `selectel` и `http`, добавьте:
|
||||
|
||||
```json
|
||||
"file": {
|
||||
"path": "/etc/han/break-glass/secrets.env",
|
||||
"max_bytes": 1048576
|
||||
}
|
||||
```
|
||||
|
||||
`secrets` map остаётся тем же. Для записей с `literal: ""` строка в fallback
|
||||
не нужна; остальные канонические ключи обязательны. Fallback parser не исполняет
|
||||
shell: запрещены `export`, substitutions, multiline, неизвестные и дублирующиеся
|
||||
ключи. Файл — `root:root 0600`.
|
||||
|
||||
При инциденте доставьте recovery-файл из защищённой офлайн-копии и только затем
|
||||
явно измените `.env`:
|
||||
|
||||
```dotenv
|
||||
SECRETS_SOURCE=file
|
||||
```
|
||||
|
||||
Перезапустите `han-secrets@production.service`, затем выполните
|
||||
validate/recreate через wrappers. После восстановления Selectel верните
|
||||
`SECRETS_SOURCE=selectel`, снова перезапустите unit, повторите проверки и
|
||||
удалите recovery-файл.
|
||||
Не храните его постоянно на ВМ: это вернуло бы исходный риск монолитного `.env`.
|
||||
|
||||
## 7. Ротация и rollback
|
||||
|
||||
1. Добавьте новую версию секрета, не меняя имя.
|
||||
2. Для canary при необходимости временно pin числовой `version` в JSON-карте.
|
||||
3. Выполните sync, validation и smoke без вывода конфигурации.
|
||||
4. Сделайте версию current, удалите pin, снова sync и пересоздайте только
|
||||
потребителей.
|
||||
5. Для rollback активируйте предыдущую provider version; не храните snapshot
|
||||
старого `.env`.
|
||||
|
||||
Selectel не позволяет удалить отдельную версию — только секрет целиком. Старые
|
||||
значения должны быть отозваны в PostgreSQL/S3/Bitrix/i-Digital после окна
|
||||
rollback. Все значения из прежнего `.env` считайте раскрытыми и ротируйте после
|
||||
перехода.
|
||||
@@ -0,0 +1,190 @@
|
||||
{
|
||||
"version": 1,
|
||||
"mode": "selectel",
|
||||
"runtime_dir": "/run/han-chat/secrets",
|
||||
"http": {
|
||||
"timeout_seconds": 10,
|
||||
"retries": 3,
|
||||
"max_response_bytes": 1048576
|
||||
},
|
||||
"selectel": {
|
||||
"account_id": "123456",
|
||||
"username": "han-secrets-reader",
|
||||
"project_name": "han-production",
|
||||
"region": "ru-9",
|
||||
"interface": "public",
|
||||
"password_file": "selectel-service-user-password"
|
||||
},
|
||||
"secrets": {
|
||||
"DATABASE_URL": {
|
||||
"remote": "DATABASE_URL",
|
||||
"consumers": ["api-backend", "api-migrate"],
|
||||
"max_bytes": 4096
|
||||
},
|
||||
"BITRIX_DATABASE_URL": {
|
||||
"remote": "BITRIX_DATABASE_URL",
|
||||
"consumers": ["bitrix-local-app", "bitrix-local-migrate"],
|
||||
"max_bytes": 4096
|
||||
},
|
||||
"BITRIX_SYNC_DATABASE_URL": {
|
||||
"remote": "BITRIX_SYNC_DATABASE_URL",
|
||||
"consumers": ["bitrix-sync", "bitrix-sync-migrate"],
|
||||
"max_bytes": 4096
|
||||
},
|
||||
"SMS_DATABASE_URL": {
|
||||
"remote": "SMS_DATABASE_URL",
|
||||
"consumers": ["sms-service", "sms-worker", "sms-migrate"],
|
||||
"max_bytes": 4096
|
||||
},
|
||||
"KEYCLOAK_DB_PASSWORD": {
|
||||
"remote": "KEYCLOAK_DB_PASSWORD",
|
||||
"consumers": ["keycloak"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"KEYCLOAK_ADMIN_PASSWORD": {
|
||||
"remote": "KEYCLOAK_ADMIN_PASSWORD",
|
||||
"consumers": ["keycloak"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"CURSOR_HMAC_SECRET": {
|
||||
"remote": "CURSOR_HMAC_SECRET",
|
||||
"consumers": ["api-backend"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"KEYCLOAK_OTP_HMAC_KEY": {
|
||||
"remote": "KEYCLOAK_OTP_HMAC_KEY",
|
||||
"consumers": ["keycloak"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"KEYCLOAK_OTP_MOCK_CODE": {
|
||||
"remote": "KEYCLOAK_OTP_MOCK_CODE",
|
||||
"consumers": ["keycloak"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY": {
|
||||
"remote": "KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY",
|
||||
"consumers": ["keycloak"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"BITRIX_TOKEN_ENCRYPTION_KEY": {
|
||||
"remote": "BITRIX_TOKEN_ENCRYPTION_KEY",
|
||||
"consumers": ["api-backend", "bitrix-local-app", "bitrix-sync"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"REDIS_API_PASSWORD": {
|
||||
"remote": "REDIS_API_PASSWORD",
|
||||
"consumers": ["redis"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"REDIS_URL": {
|
||||
"remote": "REDIS_URL",
|
||||
"consumers": ["api-backend", "delivery-worker", "cleanup-worker"],
|
||||
"max_bytes": 4096
|
||||
},
|
||||
"REDIS_REALTIME_URL": {
|
||||
"remote": "REDIS_REALTIME_URL",
|
||||
"consumers": ["api-backend"],
|
||||
"max_bytes": 4096
|
||||
},
|
||||
"REDIS_SAFETY_PASSWORD": {
|
||||
"remote": "REDIS_SAFETY_PASSWORD",
|
||||
"consumers": ["redis"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"MESSAGE_SAFETY_REDIS_URL": {
|
||||
"remote": "MESSAGE_SAFETY_REDIS_URL",
|
||||
"consumers": ["message-safety", "safety-recovery-worker"],
|
||||
"max_bytes": 4096
|
||||
},
|
||||
"REDIS_HEALTH_PASSWORD": {
|
||||
"remote": "REDIS_HEALTH_PASSWORD",
|
||||
"consumers": ["redis", "redis-exporter"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"MESSAGE_SAFETY_SERVICE_TOKEN": {
|
||||
"remote": "MESSAGE_SAFETY_SERVICE_TOKEN",
|
||||
"consumers": ["api-backend", "message-safety"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"BITRIX_LOCAL_APP_INTERNAL_TOKEN": {
|
||||
"remote": "BITRIX_LOCAL_APP_INTERNAL_TOKEN",
|
||||
"consumers": ["api-backend", "bitrix-local-app"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"BITRIX_INTERNAL_API_TOKEN": {
|
||||
"remote": "BITRIX_LOCAL_APP_INTERNAL_TOKEN",
|
||||
"consumers": ["api-backend", "bitrix-local-app"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"BITRIX_API_FORWARD_TOKEN": {
|
||||
"remote": "BITRIX_API_FORWARD_TOKEN",
|
||||
"consumers": ["api-backend", "bitrix-local-app"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"BITRIX_API_INBOX_TOKEN": {
|
||||
"remote": "BITRIX_API_FORWARD_TOKEN",
|
||||
"consumers": ["api-backend", "bitrix-local-app"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"BITRIX_SYNC_SERVICE_TOKEN": {
|
||||
"remote": "BITRIX_SYNC_SERVICE_TOKEN",
|
||||
"consumers": ["api-backend", "bitrix-sync"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN": {
|
||||
"remote": "KEYCLOAK_SETTINGS_BRIDGE_TOKEN",
|
||||
"consumers": ["api-backend", "keycloak"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"SMS_SERVICE_TOKEN": {
|
||||
"remote": "SMS_SERVICE_TOKEN",
|
||||
"consumers": ["sms-service", "sms-worker", "keycloak"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"KEYCLOAK_SMS_SERVICE_TOKEN": {
|
||||
"remote": "SMS_SERVICE_TOKEN",
|
||||
"consumers": ["sms-service", "sms-worker", "keycloak"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"IDGTL_SMS_API_KEY": {
|
||||
"remote": "IDGTL_SMS_API_KEY",
|
||||
"consumers": ["sms-service", "sms-worker"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"IDGTL_SMS_CALLBACK_USERNAME": {
|
||||
"remote": "IDGTL_SMS_CALLBACK_USERNAME",
|
||||
"consumers": ["sms-service", "sms-worker"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"IDGTL_SMS_CALLBACK_PASSWORD": {
|
||||
"remote": "IDGTL_SMS_CALLBACK_PASSWORD",
|
||||
"consumers": ["sms-service", "sms-worker"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"BITRIX_CLIENT_SECRET": {
|
||||
"remote": "BITRIX_CLIENT_SECRET",
|
||||
"consumers": ["bitrix-local-app"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"BITRIX_APPLICATION_TOKEN": {
|
||||
"remote": "BITRIX_APPLICATION_TOKEN",
|
||||
"consumers": ["bitrix-local-app"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"SELECTEL_S3_SECRET_KEY": {
|
||||
"remote": "SELECTEL_S3_SECRET_KEY",
|
||||
"consumers": ["api-backend"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"SELECTEL_S3_ACCESS_KEY": {
|
||||
"remote": "SELECTEL_S3_ACCESS_KEY",
|
||||
"consumers": ["api-backend"],
|
||||
"max_bytes": 1024
|
||||
},
|
||||
"OTEL_REMOTE_AUTH_HEADER": {
|
||||
"literal": "",
|
||||
"consumers": ["otel-collector"],
|
||||
"max_bytes": 4096
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
[Unit]
|
||||
# Enable only on a dedicated HAN Chat Docker host after the Selectel canary and
|
||||
# file fallback have both passed. A failed secret sync intentionally blocks
|
||||
# Docker startup so containers cannot race an empty /run directory.
|
||||
Requires=han-secrets@production.service
|
||||
After=han-secrets@production.service
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
DEPLOY_DIR=${HAN_DEPLOY_DIR:-/opt/han-chat/backend}
|
||||
SOURCE_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
|
||||
[ ! -f "$SOURCE_DIR/../../docker-compose.yml" ] || \
|
||||
DEPLOY_DIR=$(CDPATH= cd -- "$SOURCE_DIR/../.." && pwd)
|
||||
cd "$DEPLOY_DIR"
|
||||
|
||||
CONFIG_FILE=${CONFIG_FILE:-.env}
|
||||
LAUNCHER=${HAN_SECRETS_LAUNCHER:-/usr/local/lib/han-secrets/han-secrets}
|
||||
[ -x "$LAUNCHER" ] || LAUNCHER=deployment/secrets/han-secrets
|
||||
|
||||
exec "$LAUNCHER" run --config "$CONFIG_FILE" -- \
|
||||
docker compose --env-file "$CONFIG_FILE" "$@"
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Synchronize runtime secrets and execute a command without exporting their values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from secrets_loader import LoaderError, load_json, run
|
||||
|
||||
|
||||
def load_public_config(path: Path) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError as exc:
|
||||
raise LoaderError(
|
||||
f"cannot read non-secret config: {exc.strerror or exc.__class__.__name__}"
|
||||
) from None
|
||||
for number, raw in enumerate(lines, 1):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" not in line:
|
||||
raise LoaderError(f"non-secret config has invalid syntax at line {number}")
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
if not key or key in values:
|
||||
raise LoaderError(f"non-secret config has an invalid key at line {number}")
|
||||
values[key] = value.strip()
|
||||
return values
|
||||
|
||||
|
||||
def loader_config_path(
|
||||
public: dict[str, str],
|
||||
explicit: Path | None,
|
||||
environ: dict[str, str],
|
||||
) -> tuple[str, Path]:
|
||||
source = public.get("SECRETS_SOURCE")
|
||||
if source not in {"selectel", "file"}:
|
||||
raise LoaderError("SECRETS_SOURCE must explicitly be selectel or file")
|
||||
if explicit is not None:
|
||||
return source, explicit
|
||||
override = environ.get(f"HAN_SECRETS_{source.upper()}_CONFIG")
|
||||
if override:
|
||||
return source, Path(override)
|
||||
environment = public.get("APP_ENV", "production")
|
||||
return source, Path(f"/etc/han/secrets/{environment}.{source}.json")
|
||||
|
||||
|
||||
def prepare_environment(
|
||||
config_path: Path,
|
||||
source: str,
|
||||
environ: dict[str, str],
|
||||
*,
|
||||
synchronize: bool,
|
||||
) -> dict[str, str]:
|
||||
document = load_json(config_path)
|
||||
if document.get("mode") != source:
|
||||
raise LoaderError("selected loader configuration mode does not match SECRETS_SOURCE")
|
||||
runtime = document.get("runtime_dir")
|
||||
if not isinstance(runtime, str) or not Path(runtime).is_absolute():
|
||||
raise LoaderError("loader configuration has an invalid runtime_dir")
|
||||
runtime_dir = Path(runtime)
|
||||
manifest = runtime_dir / "manifest"
|
||||
state_path = runtime_dir / "state.json"
|
||||
consumers: list[str] = []
|
||||
if synchronize or (source == "file" and not state_path.is_file()):
|
||||
consumers = run(config_path, environ=environ)
|
||||
state = {
|
||||
"version": 1,
|
||||
"source": source,
|
||||
"loader_config": str(config_path.resolve()),
|
||||
}
|
||||
descriptor, temporary = tempfile.mkstemp(
|
||||
prefix=".state.", suffix=".tmp", dir=runtime_dir
|
||||
)
|
||||
temporary_path = Path(temporary)
|
||||
try:
|
||||
os.chmod(temporary_path, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||
descriptor = -1
|
||||
json.dump(state, stream, separators=(",", ":"))
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary_path, state_path)
|
||||
except BaseException:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
raise
|
||||
elif not state_path.is_file():
|
||||
raise LoaderError(
|
||||
"runtime secrets are not synchronized; restart han-secrets systemd unit"
|
||||
)
|
||||
else:
|
||||
state = load_json(state_path)
|
||||
if (
|
||||
state.get("version") != 1
|
||||
or state.get("source") != source
|
||||
or state.get("loader_config") != str(config_path.resolve())
|
||||
):
|
||||
raise LoaderError(
|
||||
"runtime secret state does not match selected source/config; synchronize first"
|
||||
)
|
||||
if not manifest.is_file():
|
||||
raise LoaderError("runtime secret manifest was not materialized")
|
||||
manifest_entries: dict[str, str] = {}
|
||||
for line in manifest.read_text(encoding="utf-8").splitlines():
|
||||
key, value_path = line.split("=", 1)
|
||||
manifest_entries[key] = value_path
|
||||
specs = document.get("secrets")
|
||||
if not isinstance(specs, dict) or set(manifest_entries) != set(specs):
|
||||
raise LoaderError("runtime secret manifest does not match loader configuration")
|
||||
child = dict(environ)
|
||||
child["HAN_SECRETS_ACTIVE"] = "1"
|
||||
child["HAN_RUNTIME_SECRET_DIR"] = str(runtime_dir)
|
||||
child["HAN_RUNTIME_SECRET_MANIFEST"] = str(manifest)
|
||||
for key, value_path in manifest_entries.items():
|
||||
if not Path(value_path).is_file():
|
||||
raise LoaderError("runtime secret manifest references a missing file")
|
||||
child[f"{key}_FILE"] = value_path
|
||||
if synchronize or consumers:
|
||||
print(
|
||||
f"han-secrets: synchronized {len(consumers)} service scope(s) from {source}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return child
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=("sync", "run"))
|
||||
parser.add_argument("--config", type=Path, default=Path(".env"))
|
||||
parser.add_argument("--loader-config", type=Path)
|
||||
arguments, command = parser.parse_known_args(argv)
|
||||
if command and command[0] == "--":
|
||||
command.pop(0)
|
||||
if arguments.action == "run" and not command:
|
||||
parser.error("run requires a command after --")
|
||||
if arguments.action == "sync" and command:
|
||||
parser.error("sync does not accept a command")
|
||||
|
||||
environment = dict(os.environ)
|
||||
try:
|
||||
public = load_public_config(arguments.config)
|
||||
source, loader_config = loader_config_path(
|
||||
public, arguments.loader_config, environment
|
||||
)
|
||||
child = prepare_environment(
|
||||
loader_config,
|
||||
source,
|
||||
environment,
|
||||
synchronize=arguments.action == "sync",
|
||||
)
|
||||
except (LoaderError, OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
message = str(exc) if isinstance(exc, LoaderError) else exc.__class__.__name__
|
||||
print(f"han-secrets: {message}", file=sys.stderr)
|
||||
return 1
|
||||
if arguments.action == "sync":
|
||||
return 0
|
||||
if os.name == "nt":
|
||||
return subprocess.call(command, env=child)
|
||||
os.execvpe(command[0], command, child)
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,42 @@
|
||||
[Unit]
|
||||
Description=Materialize HAN service secrets (%i)
|
||||
Documentation=file:/usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
Before=han-stack@%i.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=root
|
||||
UMask=0077
|
||||
RuntimeDirectory=han-chat/secrets
|
||||
RuntimeDirectoryMode=0700
|
||||
ExecStart=/usr/bin/python3 /usr/local/lib/han-secrets/han-secrets sync --config /opt/han-chat/backend/.env
|
||||
LoadCredentialEncrypted=selectel-service-user-password:/etc/han/credentials/%i.selectel-password.cred
|
||||
RemainAfterExit=yes
|
||||
StandardOutput=null
|
||||
StandardError=journal
|
||||
SyslogIdentifier=han-secrets-%i
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
LimitCORE=0
|
||||
SystemCallArchitectures=native
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,682 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Materialize narrowly scoped service dotenv files from Selectel Secrets Manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import ssl
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, NoReturn
|
||||
|
||||
DEFAULT_IDENTITY_URL = "https://cloud.api.selcloud.ru/identity/v3/auth/tokens"
|
||||
MAX_CONFIG_BYTES = 1_048_576
|
||||
MAX_HTTP_BYTES = 1_048_576
|
||||
MAX_SECRET_BYTES = 65_536
|
||||
RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})
|
||||
ENV_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
||||
SERVICE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$")
|
||||
DOTENV_LINE_RE = re.compile(r"^([A-Z][A-Z0-9_]*)=(.*)$")
|
||||
|
||||
|
||||
class LoaderError(Exception):
|
||||
"""An expected, already-redacted loader failure."""
|
||||
|
||||
|
||||
def fail(message: str) -> NoReturn:
|
||||
raise LoaderError(message)
|
||||
|
||||
|
||||
def _object(value: Any, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
fail(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _only_keys(value: Mapping[str, Any], allowed: set[str], label: str) -> None:
|
||||
unknown = sorted(set(value) - allowed)
|
||||
if unknown:
|
||||
fail(f"{label} contains unsupported fields: {', '.join(unknown)}")
|
||||
|
||||
|
||||
def _required_string(value: Mapping[str, Any], key: str, label: str) -> str:
|
||||
item = value.get(key)
|
||||
if not isinstance(item, str) or not item:
|
||||
fail(f"{label}.{key} must be a non-empty string")
|
||||
return item
|
||||
|
||||
|
||||
def _bounded_int(value: Any, label: str, minimum: int, maximum: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
|
||||
fail(f"{label} must be an integer from {minimum} through {maximum}")
|
||||
return value
|
||||
|
||||
|
||||
def read_limited(path: Path, limit: int, label: str) -> bytes:
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
data = stream.read(limit + 1)
|
||||
except OSError as exc:
|
||||
fail(f"cannot read {label}: {exc.strerror or exc.__class__.__name__}")
|
||||
if len(data) > limit:
|
||||
fail(f"{label} exceeds {limit} bytes")
|
||||
return data
|
||||
|
||||
|
||||
def require_private_regular_file(path: Path, label: str) -> None:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
fail(f"cannot inspect {label}: {exc.strerror or exc.__class__.__name__}")
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
||||
fail(f"{label} must be a regular file and not a symlink")
|
||||
if os.name != "nt" and stat.S_IMODE(metadata.st_mode) & 0o077:
|
||||
fail(f"{label} must not be accessible by group or other users")
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
raw = read_limited(path, MAX_CONFIG_BYTES, "configuration")
|
||||
try:
|
||||
document = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
fail("configuration is not valid UTF-8 JSON")
|
||||
return _object(document, "configuration")
|
||||
|
||||
|
||||
def credential_value(selectel: Mapping[str, Any], environ: Mapping[str, str]) -> str:
|
||||
methods = sum(key in selectel for key in ("password_file", "password_env"))
|
||||
if methods != 1:
|
||||
fail("selectel must set exactly one of password_file or password_env")
|
||||
if "password_env" in selectel:
|
||||
variable = _required_string(selectel, "password_env", "selectel")
|
||||
if not ENV_NAME_RE.fullmatch(variable):
|
||||
fail("selectel.password_env is not a valid environment variable name")
|
||||
value = environ.get(variable)
|
||||
if value is None or not value:
|
||||
fail(f"credential environment variable {variable} is not set")
|
||||
return value
|
||||
|
||||
configured = Path(_required_string(selectel, "password_file", "selectel"))
|
||||
if configured.is_absolute():
|
||||
path = configured
|
||||
else:
|
||||
directory = environ.get("CREDENTIALS_DIRECTORY")
|
||||
if not directory:
|
||||
fail("relative password_file requires CREDENTIALS_DIRECTORY")
|
||||
path = Path(directory) / configured
|
||||
require_private_regular_file(path, "credential")
|
||||
raw = read_limited(path, 16_384, "credential")
|
||||
try:
|
||||
value = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
fail("credential is not valid UTF-8")
|
||||
value = value.removesuffix("\n").removesuffix("\r")
|
||||
if not value or "\n" in value or "\r" in value or "\x00" in value:
|
||||
fail("credential must contain exactly one non-empty text line")
|
||||
return value
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HTTPResult:
|
||||
status: int
|
||||
headers: Mapping[str, str]
|
||||
body: bytes
|
||||
|
||||
|
||||
class HTTPClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
timeout: float,
|
||||
retries: int,
|
||||
max_response_bytes: int,
|
||||
cafile: str | None = None,
|
||||
opener: Any | None = None,
|
||||
sleeper: Callable[[float], None] = time.sleep,
|
||||
jitter: Callable[[], float] = random.random,
|
||||
) -> None:
|
||||
self.timeout = timeout
|
||||
self.retries = retries
|
||||
self.max_response_bytes = max_response_bytes
|
||||
self.sleeper = sleeper
|
||||
self.jitter = jitter
|
||||
if opener is None:
|
||||
try:
|
||||
context = ssl.create_default_context(cafile=cafile)
|
||||
except (OSError, ssl.SSLError) as exc:
|
||||
fail(f"cannot initialize TLS trust store: {exc.__class__.__name__}")
|
||||
self.opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPSHandler(context=context), NoRedirect()
|
||||
)
|
||||
else:
|
||||
self.opener = opener
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
body: bytes | None = None,
|
||||
expected: frozenset[int],
|
||||
) -> HTTPResult:
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
|
||||
fail("provider endpoint must be an HTTPS URL without embedded credentials")
|
||||
request = urllib.request.Request(
|
||||
url, data=body, headers=dict(headers or {}), method=method
|
||||
)
|
||||
for attempt in range(self.retries + 1):
|
||||
try:
|
||||
with self.opener.open(request, timeout=self.timeout) as response:
|
||||
status = int(response.status)
|
||||
content_length = response.headers.get("Content-Length")
|
||||
if content_length:
|
||||
try:
|
||||
if int(content_length) > self.max_response_bytes:
|
||||
fail("provider response exceeds configured limit")
|
||||
except ValueError:
|
||||
fail("provider returned an invalid Content-Length")
|
||||
response_body = response.read(self.max_response_bytes + 1)
|
||||
if len(response_body) > self.max_response_bytes:
|
||||
fail("provider response exceeds configured limit")
|
||||
if status not in expected:
|
||||
fail(f"provider request failed with HTTP {status}")
|
||||
return HTTPResult(status, response.headers, response_body)
|
||||
except urllib.error.HTTPError as exc:
|
||||
status = int(exc.code)
|
||||
if status not in RETRYABLE_STATUS or attempt >= self.retries:
|
||||
fail(f"provider request failed with HTTP {status}")
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
if attempt >= self.retries:
|
||||
fail("provider request failed after retries")
|
||||
delay = min(8.0, 0.25 * (2**attempt)) * (0.5 + self.jitter())
|
||||
self.sleeper(delay)
|
||||
fail("provider request failed")
|
||||
|
||||
|
||||
def parse_json_response(result: HTTPResult, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
return _object(json.loads(result.body.decode("utf-8")), label)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
fail(f"{label} is not valid JSON")
|
||||
|
||||
|
||||
def project_token_and_catalog(
|
||||
client: HTTPClient, selectel: Mapping[str, Any], password: str
|
||||
) -> tuple[str, list[Any]]:
|
||||
identity_url = selectel.get("identity_url", DEFAULT_IDENTITY_URL)
|
||||
if not isinstance(identity_url, str):
|
||||
fail("selectel.identity_url must be a string")
|
||||
account_id = _required_string(selectel, "account_id", "selectel")
|
||||
username = _required_string(selectel, "username", "selectel")
|
||||
project_name = _required_string(selectel, "project_name", "selectel")
|
||||
payload = {
|
||||
"auth": {
|
||||
"identity": {
|
||||
"methods": ["password"],
|
||||
"password": {
|
||||
"user": {
|
||||
"name": username,
|
||||
"domain": {"name": account_id},
|
||||
"password": password,
|
||||
}
|
||||
},
|
||||
},
|
||||
"scope": {
|
||||
"project": {
|
||||
"name": project_name,
|
||||
"domain": {"name": account_id},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
result = client.request(
|
||||
"POST",
|
||||
identity_url,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
body=json.dumps(payload, separators=(",", ":")).encode("utf-8"),
|
||||
expected=frozenset({201}),
|
||||
)
|
||||
token = result.headers.get("X-Subject-Token")
|
||||
if not isinstance(token, str) or not token:
|
||||
fail("identity response omitted X-Subject-Token")
|
||||
document = parse_json_response(result, "identity response")
|
||||
token_data = document.get("token")
|
||||
if not isinstance(token_data, dict):
|
||||
fail("identity response omitted token metadata")
|
||||
project = token_data.get("project")
|
||||
if not isinstance(project, dict) or not project.get("id"):
|
||||
fail("identity token is not project-scoped")
|
||||
catalog = token_data.get("catalog")
|
||||
if not isinstance(catalog, list):
|
||||
fail("identity response omitted service catalog")
|
||||
return token, catalog
|
||||
|
||||
|
||||
def secrets_endpoint(catalog: list[Any], region: str, interface: str) -> str:
|
||||
matches: list[str] = []
|
||||
for service in catalog:
|
||||
if not isinstance(service, dict) or service.get("type") != "secrets-manager":
|
||||
continue
|
||||
endpoints = service.get("endpoints")
|
||||
if not isinstance(endpoints, list):
|
||||
continue
|
||||
for endpoint in endpoints:
|
||||
if (
|
||||
isinstance(endpoint, dict)
|
||||
and endpoint.get("region") == region
|
||||
and endpoint.get("interface") == interface
|
||||
and isinstance(endpoint.get("url"), str)
|
||||
):
|
||||
matches.append(endpoint["url"].rstrip("/"))
|
||||
if len(matches) != 1:
|
||||
fail("service catalog did not contain exactly one matching Secrets Manager endpoint")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def decode_secret(document: Mapping[str, Any], name: str, limit: int) -> bytes:
|
||||
# GET /v1/{name} returns the current value inside ``version`` while
|
||||
# GET /v1/{name}/versions/{id} returns a version object directly.
|
||||
payload: Mapping[str, Any] = document
|
||||
version = document.get("version")
|
||||
if isinstance(version, dict):
|
||||
payload = version
|
||||
encoded = payload.get("value")
|
||||
if not isinstance(encoded, str):
|
||||
fail(f"secret {name} response omitted base64 value")
|
||||
try:
|
||||
value = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
fail(f"secret {name} has invalid base64 encoding")
|
||||
if not value:
|
||||
fail(f"secret {name} is empty")
|
||||
if len(value) > limit:
|
||||
fail(f"secret {name} exceeds its configured limit")
|
||||
if b"\x00" in value or b"\n" in value or b"\r" in value:
|
||||
fail(f"secret {name} cannot be represented as a dotenv value")
|
||||
return value
|
||||
|
||||
|
||||
def fetch_selectel(
|
||||
config: Mapping[str, Any],
|
||||
specs: Mapping[str, Mapping[str, Any]],
|
||||
environ: Mapping[str, str],
|
||||
client_factory: Callable[..., HTTPClient] = HTTPClient,
|
||||
) -> dict[str, bytes]:
|
||||
selectel = _object(config.get("selectel"), "selectel")
|
||||
_only_keys(
|
||||
selectel,
|
||||
{
|
||||
"account_id",
|
||||
"username",
|
||||
"project_name",
|
||||
"region",
|
||||
"interface",
|
||||
"password_file",
|
||||
"password_env",
|
||||
"identity_url",
|
||||
"secrets_url",
|
||||
"ca_file",
|
||||
},
|
||||
"selectel",
|
||||
)
|
||||
http = _object(config.get("http", {}), "http")
|
||||
_only_keys(http, {"timeout_seconds", "retries", "max_response_bytes"}, "http")
|
||||
timeout = http.get("timeout_seconds", 10)
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not 0.1 <= timeout <= 60:
|
||||
fail("http.timeout_seconds must be from 0.1 through 60")
|
||||
retries = _bounded_int(http.get("retries", 3), "http.retries", 0, 8)
|
||||
response_limit = _bounded_int(
|
||||
http.get("max_response_bytes", MAX_HTTP_BYTES),
|
||||
"http.max_response_bytes",
|
||||
1024,
|
||||
4 * MAX_HTTP_BYTES,
|
||||
)
|
||||
cafile = selectel.get("ca_file")
|
||||
if cafile is not None and (not isinstance(cafile, str) or not cafile):
|
||||
fail("selectel.ca_file must be a non-empty string")
|
||||
client = client_factory(
|
||||
timeout=float(timeout),
|
||||
retries=retries,
|
||||
max_response_bytes=response_limit,
|
||||
cafile=cafile,
|
||||
)
|
||||
password = credential_value(selectel, environ)
|
||||
token, catalog = project_token_and_catalog(client, selectel, password)
|
||||
region = _required_string(selectel, "region", "selectel")
|
||||
interface = selectel.get("interface", "public")
|
||||
if interface not in {"public", "internal"}:
|
||||
fail("selectel.interface must be public or internal")
|
||||
override = selectel.get("secrets_url")
|
||||
if override is not None and (not isinstance(override, str) or not override):
|
||||
fail("selectel.secrets_url must be a non-empty string")
|
||||
base_url = override.rstrip("/") if override else secrets_endpoint(catalog, region, interface)
|
||||
|
||||
values: dict[str, bytes] = {}
|
||||
fetched: dict[tuple[str, int | None], bytes] = {}
|
||||
for canonical, spec in specs.items():
|
||||
if "literal" in spec:
|
||||
values[canonical] = b""
|
||||
continue
|
||||
remote = _required_string(spec, "remote", f"secrets.{canonical}")
|
||||
version = spec.get("version")
|
||||
version_id: int | None = None
|
||||
if version is not None:
|
||||
version_id = _bounded_int(
|
||||
version, f"secrets.{canonical}.version", 1, 2_147_483_647
|
||||
)
|
||||
cache_key = (remote, version_id)
|
||||
if cache_key not in fetched:
|
||||
path = f"/v1/{urllib.parse.quote(remote, safe='')}"
|
||||
if version_id is not None:
|
||||
path += f"/versions/{version_id}"
|
||||
try:
|
||||
result = client.request(
|
||||
"GET",
|
||||
base_url + path,
|
||||
headers={"X-Auth-Token": token, "Accept": "application/json"},
|
||||
expected=frozenset({200}),
|
||||
)
|
||||
document = parse_json_response(result, f"secret {canonical} response")
|
||||
fetched[cache_key] = decode_secret(
|
||||
document, canonical, MAX_SECRET_BYTES
|
||||
)
|
||||
except LoaderError as exc:
|
||||
fail(f"cannot load {canonical}: {exc}")
|
||||
limit = _bounded_int(
|
||||
spec.get("max_bytes", MAX_SECRET_BYTES),
|
||||
f"secrets.{canonical}.max_bytes",
|
||||
1,
|
||||
MAX_SECRET_BYTES,
|
||||
)
|
||||
value = fetched[cache_key]
|
||||
if len(value) > limit:
|
||||
fail(f"secret {canonical} exceeds its configured limit")
|
||||
values[canonical] = value
|
||||
return values
|
||||
|
||||
|
||||
def parse_dotenv(path: Path, expected: set[str], max_bytes: int) -> dict[str, bytes]:
|
||||
require_private_regular_file(path, "fallback dotenv")
|
||||
raw = read_limited(path, max_bytes, "fallback dotenv")
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
fail("fallback dotenv is not valid UTF-8")
|
||||
values: dict[str, bytes] = {}
|
||||
for number, line in enumerate(text.splitlines(), 1):
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
match = DOTENV_LINE_RE.fullmatch(line)
|
||||
if not match:
|
||||
fail(f"fallback dotenv has invalid syntax at line {number}")
|
||||
name, encoded_value = match.groups()
|
||||
if name not in expected:
|
||||
fail(f"fallback dotenv contains undeclared key {name}")
|
||||
if name in values:
|
||||
fail(f"fallback dotenv contains duplicate key {name}")
|
||||
if encoded_value.startswith('"'):
|
||||
try:
|
||||
decoded = json.loads(encoded_value)
|
||||
except json.JSONDecodeError:
|
||||
fail(f"fallback dotenv has invalid quoted value at line {number}")
|
||||
if not isinstance(decoded, str):
|
||||
fail(f"fallback dotenv has invalid quoted value at line {number}")
|
||||
value = decoded.encode("utf-8")
|
||||
elif encoded_value.startswith("'"):
|
||||
if len(encoded_value) < 2 or not encoded_value.endswith("'"):
|
||||
fail(f"fallback dotenv has invalid quoted value at line {number}")
|
||||
value = encoded_value[1:-1].encode("utf-8")
|
||||
else:
|
||||
if any(character.isspace() for character in encoded_value) or any(
|
||||
character in encoded_value for character in ("'", '"', "`", "$", "\\")
|
||||
):
|
||||
fail(f"fallback dotenv requires quoting at line {number}")
|
||||
value = encoded_value.encode("utf-8")
|
||||
if not value or b"\x00" in value or b"\n" in value or b"\r" in value:
|
||||
fail(f"fallback dotenv has an empty or unsafe value for {name}")
|
||||
values[name] = value
|
||||
missing = sorted(expected - set(values))
|
||||
if missing:
|
||||
fail(f"fallback dotenv is missing declared keys: {', '.join(missing)}")
|
||||
return values
|
||||
|
||||
|
||||
def validate_specs(config: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
raw_specs = _object(config.get("secrets"), "secrets")
|
||||
if not raw_specs:
|
||||
fail("secrets must not be empty")
|
||||
specs: dict[str, dict[str, Any]] = {}
|
||||
for canonical, raw_spec in raw_specs.items():
|
||||
if not isinstance(canonical, str) or not ENV_NAME_RE.fullmatch(canonical):
|
||||
fail("every canonical secret name must be an uppercase environment name")
|
||||
spec = _object(raw_spec, f"secrets.{canonical}")
|
||||
_only_keys(
|
||||
spec,
|
||||
{"remote", "consumers", "max_bytes", "version", "literal"},
|
||||
f"secrets.{canonical}",
|
||||
)
|
||||
has_remote = "remote" in spec
|
||||
has_literal = "literal" in spec
|
||||
if has_remote == has_literal:
|
||||
fail(f"secrets.{canonical} must set exactly one of remote or literal")
|
||||
if has_literal and spec["literal"] != "":
|
||||
fail(f"secrets.{canonical}.literal may only be an empty string")
|
||||
consumers = spec.get("consumers")
|
||||
if not isinstance(consumers, list) or not consumers:
|
||||
fail(f"secrets.{canonical}.consumers must be a non-empty array")
|
||||
if len(consumers) != len(set(item for item in consumers if isinstance(item, str))):
|
||||
fail(f"secrets.{canonical}.consumers contains duplicates or invalid values")
|
||||
for consumer in consumers:
|
||||
if not isinstance(consumer, str) or not SERVICE_NAME_RE.fullmatch(consumer):
|
||||
fail(f"secrets.{canonical}.consumers contains an invalid service name")
|
||||
specs[canonical] = spec
|
||||
return specs
|
||||
|
||||
|
||||
def dotenv_quote(value: bytes, name: str) -> str:
|
||||
try:
|
||||
text = value.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
fail(f"secret {name} is not valid UTF-8")
|
||||
return json.dumps(text, ensure_ascii=False)
|
||||
|
||||
|
||||
def materialize(runtime_dir: Path, specs: Mapping[str, Mapping[str, Any]], values: Mapping[str, bytes]) -> None:
|
||||
try:
|
||||
runtime_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if runtime_dir.is_symlink():
|
||||
fail("runtime directory must not be a symlink")
|
||||
os.chmod(runtime_dir, 0o700)
|
||||
except OSError as exc:
|
||||
fail(f"cannot prepare runtime directory: {exc.strerror or exc.__class__.__name__}")
|
||||
consumers = sorted(
|
||||
{consumer for spec in specs.values() for consumer in spec["consumers"]}
|
||||
)
|
||||
staged: list[tuple[Path, Path]] = []
|
||||
try:
|
||||
for consumer in consumers:
|
||||
lines = [
|
||||
f"{name}={dotenv_quote(values[name], name)}\n"
|
||||
for name, spec in sorted(specs.items())
|
||||
if consumer in spec["consumers"]
|
||||
]
|
||||
descriptor, temporary = tempfile.mkstemp(
|
||||
prefix=f".{consumer}.", suffix=".tmp", dir=runtime_dir
|
||||
)
|
||||
temporary_path = Path(temporary)
|
||||
try:
|
||||
os.chmod(temporary_path, 0o600)
|
||||
stream = os.fdopen(descriptor, "w", encoding="utf-8", newline="\n")
|
||||
descriptor = -1
|
||||
with stream:
|
||||
stream.writelines(lines)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
except BaseException:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
staged.append((temporary_path, runtime_dir / f"{consumer}.env"))
|
||||
for temporary_path, destination in staged:
|
||||
os.replace(temporary_path, destination)
|
||||
value_paths: dict[str, Path] = {}
|
||||
for name, value in sorted(values.items()):
|
||||
descriptor, temporary = tempfile.mkstemp(
|
||||
prefix=f".{name}.", suffix=".tmp", dir=runtime_dir
|
||||
)
|
||||
temporary_path = Path(temporary)
|
||||
try:
|
||||
# Compose implements local secrets as bind mounts. The protected
|
||||
# 0700 parent prevents host users from traversing to this 0444
|
||||
# file while allowing a non-root container UID to read its mount.
|
||||
os.chmod(temporary_path, 0o444)
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
descriptor = -1
|
||||
stream.write(value)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
except BaseException:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
raise
|
||||
destination = runtime_dir / name
|
||||
os.replace(temporary_path, destination)
|
||||
value_paths[name] = destination
|
||||
|
||||
manifest_lines = [
|
||||
f"{name}={path.resolve()}\n" for name, path in sorted(value_paths.items())
|
||||
]
|
||||
descriptor, temporary = tempfile.mkstemp(
|
||||
prefix=".manifest.", suffix=".tmp", dir=runtime_dir
|
||||
)
|
||||
manifest_path = Path(temporary)
|
||||
try:
|
||||
os.chmod(manifest_path, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
|
||||
descriptor = -1
|
||||
stream.writelines(manifest_lines)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
except BaseException:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
manifest_path.unlink(missing_ok=True)
|
||||
raise
|
||||
os.replace(manifest_path, runtime_dir / "manifest")
|
||||
if hasattr(os, "O_DIRECTORY"):
|
||||
directory_fd = os.open(runtime_dir, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
except OSError as exc:
|
||||
fail(f"cannot atomically materialize service files: {exc.strerror or exc.__class__.__name__}")
|
||||
finally:
|
||||
for temporary_path, _ in staged:
|
||||
try:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def run(
|
||||
config_path: Path,
|
||||
*,
|
||||
runtime_override: Path | None = None,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
client_factory: Callable[..., HTTPClient] = HTTPClient,
|
||||
) -> list[str]:
|
||||
os.umask(0o077)
|
||||
environment = os.environ if environ is None else environ
|
||||
config = load_json(config_path)
|
||||
_only_keys(config, {"version", "mode", "runtime_dir", "http", "selectel", "file", "secrets"}, "configuration")
|
||||
if config.get("version") != 1:
|
||||
fail("configuration.version must be 1")
|
||||
mode = config.get("mode")
|
||||
if mode not in {"selectel", "file"}:
|
||||
fail("configuration.mode must explicitly be selectel or file")
|
||||
specs = validate_specs(config)
|
||||
if runtime_override is None:
|
||||
configured_runtime = config.get("runtime_dir")
|
||||
if not isinstance(configured_runtime, str) or not configured_runtime:
|
||||
fail("configuration.runtime_dir must be a non-empty string")
|
||||
runtime_dir = Path(configured_runtime)
|
||||
else:
|
||||
runtime_dir = runtime_override
|
||||
if not runtime_dir.is_absolute():
|
||||
fail("runtime directory must be an absolute path")
|
||||
|
||||
if mode == "selectel":
|
||||
if "file" in config:
|
||||
fail("file settings are forbidden in selectel mode")
|
||||
values = fetch_selectel(config, specs, environment, client_factory)
|
||||
else:
|
||||
if "selectel" in config or "http" in config:
|
||||
fail("selectel and http settings are forbidden in file mode")
|
||||
file_config = _object(config.get("file"), "file")
|
||||
_only_keys(file_config, {"path", "max_bytes"}, "file")
|
||||
source = Path(_required_string(file_config, "path", "file"))
|
||||
if not source.is_absolute():
|
||||
fail("file.path must be absolute")
|
||||
max_bytes = _bounded_int(
|
||||
file_config.get("max_bytes", MAX_CONFIG_BYTES),
|
||||
"file.max_bytes",
|
||||
1,
|
||||
4 * MAX_CONFIG_BYTES,
|
||||
)
|
||||
expected = {name for name, spec in specs.items() if "literal" not in spec}
|
||||
values = parse_dotenv(source, expected, max_bytes)
|
||||
values.update(
|
||||
{name: b"" for name, spec in specs.items() if "literal" in spec}
|
||||
)
|
||||
for canonical, value in values.items():
|
||||
limit = _bounded_int(
|
||||
specs[canonical].get("max_bytes", MAX_SECRET_BYTES),
|
||||
f"secrets.{canonical}.max_bytes",
|
||||
1,
|
||||
MAX_SECRET_BYTES,
|
||||
)
|
||||
if len(value) > limit:
|
||||
fail(f"secret {canonical} exceeds its configured limit")
|
||||
|
||||
materialize(runtime_dir, specs, values)
|
||||
return sorted({consumer for spec in specs.values() for consumer in spec["consumers"]})
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Materialize per-service secret dotenv files")
|
||||
parser.add_argument("--config", required=True, type=Path)
|
||||
parser.add_argument("--runtime-dir", type=Path)
|
||||
arguments = parser.parse_args(argv)
|
||||
try:
|
||||
consumers = run(arguments.config, runtime_override=arguments.runtime_dir)
|
||||
except LoaderError as exc:
|
||||
print(f"secrets-loader: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"secrets-loader: materialized {len(consumers)} service file(s)", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,28 @@
|
||||
name: han-chat
|
||||
|
||||
include:
|
||||
- path: ./infra/compose/application.yml
|
||||
- path: ./nginx/docker-compose.yml
|
||||
- path: ./redis/docker-compose.yml
|
||||
- path: ./observability/docker-compose.yml
|
||||
- path: ./deployment/docker-compose.jobs.yml
|
||||
|
||||
networks:
|
||||
public:
|
||||
name: han-chat-public
|
||||
backend:
|
||||
name: han-chat-backend
|
||||
internal: true
|
||||
egress:
|
||||
name: han-chat-egress
|
||||
observability:
|
||||
name: han-chat-observability
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
nginx-certs:
|
||||
nginx-acme-webroot:
|
||||
nginx-cache:
|
||||
frontend-static:
|
||||
otel-queue:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user