Исправлены ошибки, выявленные на тесте инфраструктуры
This commit is contained in:
@@ -288,6 +288,10 @@ SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=change-me
|
|||||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||||
```
|
```
|
||||||
|
|
||||||
|
S3-клиенты используют только virtual-hosted addressing
|
||||||
|
(`https://<bucket>.s3.storage.selcloud.ru/<object-key>`). Это часть контракта
|
||||||
|
presigned URL и CORS Selectel; path-style адресация не поддерживается приложением.
|
||||||
|
|
||||||
Все переменные — **только** в `backend/.env`. Отдельного хранилища нет.
|
Все переменные — **только** в `backend/.env`. Отдельного хранилища нет.
|
||||||
|
|
||||||
**Webhook-токены** (публичные callback, не service API): `BITRIX_APPLICATION_TOKEN`, `BITRIX_SYNC_WEBHOOK_TOKEN`.
|
**Webhook-токены** (публичные callback, не service API): `BITRIX_APPLICATION_TOKEN`, `BITRIX_SYNC_WEBHOOK_TOKEN`.
|
||||||
|
|||||||
@@ -228,7 +228,12 @@ class S3Client:
|
|||||||
endpoint_url=str(settings.selectel_s3_endpoint_url),
|
endpoint_url=str(settings.selectel_s3_endpoint_url),
|
||||||
aws_access_key_id=settings.selectel_s3_access_key.get_secret_value(),
|
aws_access_key_id=settings.selectel_s3_access_key.get_secret_value(),
|
||||||
aws_secret_access_key=settings.selectel_s3_secret_key.get_secret_value(),
|
aws_secret_access_key=settings.selectel_s3_secret_key.get_secret_value(),
|
||||||
config=Config(connect_timeout=3, read_timeout=10, retries={"max_attempts": 2}),
|
config=Config(
|
||||||
|
connect_timeout=3,
|
||||||
|
read_timeout=10,
|
||||||
|
retries={"max_attempts": 2},
|
||||||
|
s3={"addressing_style": "virtual"},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def ready(self) -> bool:
|
async def ready(self) -> bool:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
@@ -6,6 +7,7 @@ import pytest
|
|||||||
from app.integrations import (
|
from app.integrations import (
|
||||||
DependencyFailure,
|
DependencyFailure,
|
||||||
OpenLinesClient,
|
OpenLinesClient,
|
||||||
|
S3Client,
|
||||||
SafetyClient,
|
SafetyClient,
|
||||||
fresh_openlines_payload,
|
fresh_openlines_payload,
|
||||||
)
|
)
|
||||||
@@ -39,6 +41,19 @@ def settings() -> Settings:
|
|||||||
return Settings.model_validate(common)
|
return Settings.model_validate(common)
|
||||||
|
|
||||||
|
|
||||||
|
@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"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_safety_contract_status_and_service_token() -> None:
|
async def test_safety_contract_status_and_service_token() -> None:
|
||||||
async def handler(request: httpx.Request) -> httpx.Response:
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
|||||||
@@ -390,6 +390,11 @@ SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY=<SAFETY_READ_ACCESS_KEY>
|
|||||||
SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=<SAFETY_READ_SECRET_KEY>
|
SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=<SAFETY_READ_SECRET_KEY>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
### 8.7. Bitrix24
|
||||||
|
|
||||||
До установки локального приложения заполните:
|
До установки локального приложения заполните:
|
||||||
|
|||||||
+55
-5
@@ -1,5 +1,6 @@
|
|||||||
# usefull commands
|
# usefull commands
|
||||||
- Убрать переносы строк: sed -i 's/\r$//' name-file.sh
|
- Убрать переносы строк: sed -i 's/\r$//' name-file.sh
|
||||||
|
- find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} +
|
||||||
- docker compose stop # мягко останавливает контейнеры (не удаляет)
|
- docker compose stop # мягко останавливает контейнеры (не удаляет)
|
||||||
- sudo shutdown -h now # выглючить ВМ
|
- sudo shutdown -h now # выглючить ВМ
|
||||||
- Генерация паролей
|
- Генерация паролей
|
||||||
@@ -55,6 +56,7 @@ chmod +x scripts/validate-env deployment/scripts/*.sh redis/scripts/*.sh nginx/s
|
|||||||
|
|
||||||
## Создаем БД
|
## Создаем БД
|
||||||
В базе подключаем pgcrypto
|
В базе подключаем pgcrypto
|
||||||
|
Чтобы корректно работал PgBouncer выбираем session pooling
|
||||||
|
|
||||||
### Копируем СА сертификат
|
### Копируем СА сертификат
|
||||||
|
|
||||||
@@ -80,6 +82,8 @@ psql "host=master.ef54e3e4-ad3d-4b80-a6af-d63269e0895a.c.dbaas.selcloud.ru \
|
|||||||
user=dbAdmin \
|
user=dbAdmin \
|
||||||
sslmode=verify-ca"
|
sslmode=verify-ca"
|
||||||
|
|
||||||
|
(psql "host=master.ef54e3e4-ad3d-4b80-a6af-d63269e0895a.c.dbaas.selcloud.ru port=5432 dbname=han_chat user=dbAdmin sslmode=verify-ca")
|
||||||
|
|
||||||
### Заводим пользователей и схемы
|
### Заводим пользователей и схемы
|
||||||
|
|
||||||
Пользователей создаем через интерфейс селектела.
|
Пользователей создаем через интерфейс селектела.
|
||||||
@@ -94,18 +98,40 @@ psql "host=master.ef54e3e4-ad3d-4b80-a6af-d63269e0895a.c.dbaas.selcloud.ru \
|
|||||||
+ Запрещаем всем посторонним входить в схему han_app и др.
|
+ Запрещаем всем посторонним входить в схему han_app и др.
|
||||||
CREATE SCHEMA IF NOT EXISTS han_app;
|
CREATE SCHEMA IF NOT EXISTS han_app;
|
||||||
REVOKE ALL ON SCHEMA han_app FROM PUBLIC;
|
REVOKE ALL ON SCHEMA han_app FROM PUBLIC;
|
||||||
|
ALTER ROLE CURRENT_USER IN DATABASE han_chat SET search_path TO han_app;
|
||||||
|
SHOW search_path; --чтобы заработало надо переподключиться (должно быть han_app)
|
||||||
|
|
||||||
CREATE SCHEMA IF NOT EXISTS bitrix_local;
|
CREATE SCHEMA IF NOT EXISTS bitrix_local;
|
||||||
REVOKE ALL ON SCHEMA bitrix_local FROM PUBLIC;
|
REVOKE ALL ON SCHEMA bitrix_local FROM PUBLIC;
|
||||||
|
ALTER ROLE CURRENT_USER IN DATABASE han_chat SET search_path TO bitrix_local;
|
||||||
|
SHOW search_path; --чтобы заработало надо переподключиться
|
||||||
|
|
||||||
CREATE SCHEMA IF NOT EXISTS bitrix_sync;
|
CREATE SCHEMA IF NOT EXISTS bitrix_sync;
|
||||||
REVOKE ALL ON SCHEMA bitrix_sync FROM PUBLIC;
|
REVOKE ALL ON SCHEMA bitrix_sync FROM PUBLIC;
|
||||||
|
ALTER ROLE CURRENT_USER IN DATABASE han_chat SET search_path TO bitrix_sync;
|
||||||
|
SHOW search_path; --чтобы заработало надо переподключиться
|
||||||
|
|
||||||
CREATE SCHEMA IF NOT EXISTS message_safety;
|
CREATE SCHEMA IF NOT EXISTS message_safety;
|
||||||
REVOKE ALL ON SCHEMA message_safety FROM PUBLIC;
|
REVOKE ALL ON SCHEMA message_safety FROM PUBLIC;
|
||||||
|
ALTER ROLE CURRENT_USER IN DATABASE han_chat SET search_path TO message_safety;
|
||||||
|
SHOW search_path; --чтобы заработало надо переподключиться
|
||||||
|
|
||||||
CREATE SCHEMA IF NOT EXISTS keycloak;
|
CREATE SCHEMA IF NOT EXISTS keycloak;
|
||||||
REVOKE ALL ON SCHEMA keycloak FROM PUBLIC;
|
REVOKE ALL ON SCHEMA keycloak FROM PUBLIC;
|
||||||
|
ALTER ROLE CURRENT_USER IN DATABASE han_chat SET search_path TO keycloak;
|
||||||
|
SHOW search_path; --чтобы заработало надо переподключиться
|
||||||
|
|
||||||
|
Проверка search_path
|
||||||
|
SELECT r.rolname, d.datname, s.setconfig
|
||||||
|
FROM pg_db_role_setting s
|
||||||
|
JOIN pg_roles r ON r.oid = s.setrole
|
||||||
|
JOIN pg_database d ON d.oid = s.setdatabase
|
||||||
|
WHERE r.rolname in ('han_app',
|
||||||
|
'bitrix_local_app',
|
||||||
|
'bitrix_sync_user',
|
||||||
|
'message_safety_app',
|
||||||
|
'keycloak_user');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
После реализации bitrix_sync (проверить, вероятно не на все таблицы права нужны):
|
После реализации bitrix_sync (проверить, вероятно не на все таблицы права нужны):
|
||||||
@@ -146,8 +172,32 @@ R:
|
|||||||
ListBucketMultipartUploads
|
ListBucketMultipartUploads
|
||||||
ListMultipartUploadParts
|
ListMultipartUploadParts
|
||||||
|
|
||||||
Политика CORS:
|
Политика CORS (бакет han-chat-quarantine, vHosted обязателен):
|
||||||
Allowed origin: https://chat.example.ru
|
Allowed origin: https://chat.han0107.ru
|
||||||
Methods: POST, PUT, GET, HEAD
|
Methods: POST, PUT, GET, HEAD
|
||||||
Headers: Content-Type, x-amz-*
|
Headers: * (или явно content-type; wildcard x-amz-* в Selectel не работает)
|
||||||
Expose headers: ETag
|
Expose headers: ETag
|
||||||
|
|
||||||
|
# Тест инфраструктуры
|
||||||
|
|
||||||
|
mkdir -p /opt/han-chat/infratest
|
||||||
|
cd /opt/han-chat/infratest
|
||||||
|
|
||||||
|
scp -i C:\Users\MI\.ssh\hansel -r "C:\Users\MI\Documents\Assistent\HAN_chat_specification\infratest\*" root@135.106.164.58:/opt/han-chat/infratest
|
||||||
|
|
||||||
|
--настраиваю env на локальной машине и копирую на диск
|
||||||
|
scp -i C:\Users\MI\.ssh\hansel -r "C:\Users\MI\Documents\job\HAN_new_life\HANapp\Production\.env" root@135.106.164.58:/opt/han-chat/infratest/
|
||||||
|
|
||||||
|
|
||||||
|
Создаем изолированное окружение:
|
||||||
|
python3 -m venv .venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
python3 -m pip install --upgrade pip
|
||||||
|
python3 -m pip install -r requirements.txt
|
||||||
|
|
||||||
|
Если не работает, то надо DNS переправить на eth0
|
||||||
|
resolvectl dns eth0 1.1.1.1 8.8.8.8
|
||||||
|
resolvectl domain eth0 '~.'
|
||||||
|
resolvectl flush-caches
|
||||||
|
resolvectl query pypi.org
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -149,11 +149,17 @@ INFRATEST_CHECK_MIGRATIONS=true
|
|||||||
|
|
||||||
## CORS
|
## CORS
|
||||||
|
|
||||||
При `INFRATEST_CHECK_CORS=true` quarantine должен отвечать на preflight:
|
При `INFRATEST_CHECK_CORS=true` quarantine должен отвечать на preflight по
|
||||||
|
**vHosted** URL (`<bucket>.<s3_domain>`). У Selectel CORS не работает на
|
||||||
|
path-style адресации, даже если presigned PUT с сервера проходит.
|
||||||
|
|
||||||
|
Требования к правилу CORS на `han-chat-quarantine`:
|
||||||
|
|
||||||
|
- бакет с включённой Virtual-Hosted адресацией;
|
||||||
- origin — точное значение `PUBLIC_WEB_URL`, без wildcard;
|
- origin — точное значение `PUBLIC_WEB_URL`, без wildcard;
|
||||||
- method — `PUT`;
|
- method — `PUT` (можно также `GET`, `HEAD`, `POST`);
|
||||||
- headers — `Content-Type`, `x-amz-*`.
|
- headers — минимум `content-type`; wildcard `x-amz-*` в панели Selectel обычно
|
||||||
|
**не работает**, надёжнее указать `*` или перечислить заголовки явно.
|
||||||
|
|
||||||
Чтобы временно исключить CORS из диагностики:
|
Чтобы временно исключить CORS из диагностики:
|
||||||
|
|
||||||
|
|||||||
+22
-9
@@ -93,6 +93,15 @@ def sanitize(value: Any) -> str:
|
|||||||
return text[:1000]
|
return text[:1000]
|
||||||
|
|
||||||
|
|
||||||
|
def virtual_hosted_bucket_url(endpoint: str, bucket: str, key: str = "") -> str:
|
||||||
|
"""Собирает vHosted URL бакета для CORS preflight (Selectel не поддерживает CORS на path-style)."""
|
||||||
|
parsed = urlsplit(endpoint.rstrip("/"))
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise ConfigError("SELECTEL_S3_ENDPOINT_URL должен быть абсолютным https URL")
|
||||||
|
path = f"/{key.lstrip('/')}" if key else "/"
|
||||||
|
return urlunsplit((parsed.scheme, f"{bucket}.{parsed.netloc}", path, "", ""))
|
||||||
|
|
||||||
|
|
||||||
def require(values: dict[str, str], name: str) -> str:
|
def require(values: dict[str, str], name: str) -> str:
|
||||||
value = values.get(name, "").strip()
|
value = values.get(name, "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
@@ -288,6 +297,7 @@ class InfraTest:
|
|||||||
connect_timeout=self.settings.timeout_seconds,
|
connect_timeout=self.settings.timeout_seconds,
|
||||||
read_timeout=self.settings.timeout_seconds,
|
read_timeout=self.settings.timeout_seconds,
|
||||||
retries={"max_attempts": 2},
|
retries={"max_attempts": 2},
|
||||||
|
s3={"addressing_style": "virtual"},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if self.settings.s3_region:
|
if self.settings.s3_region:
|
||||||
@@ -742,14 +752,12 @@ class InfraTest:
|
|||||||
return "GET quarantine разрешён; запись, удаление и другие бакеты запрещены"
|
return "GET quarantine разрешён; запись, удаление и другие бакеты запрещены"
|
||||||
|
|
||||||
def _check_cors(self, key: str) -> str:
|
def _check_cors(self, key: str) -> str:
|
||||||
url = self.api_s3.generate_presigned_url(
|
# Selectel обрабатывает CORS только на vHosted URL; path-style presigned URL
|
||||||
"put_object",
|
# возвращает 405 даже при корректной конфигурации бакета.
|
||||||
Params={
|
url = virtual_hosted_bucket_url(
|
||||||
"Bucket": self.settings.buckets["quarantine"],
|
self.settings.s3_endpoint,
|
||||||
"Key": key,
|
self.settings.buckets["quarantine"],
|
||||||
"ContentType": "application/octet-stream",
|
key,
|
||||||
},
|
|
||||||
ExpiresIn=300,
|
|
||||||
)
|
)
|
||||||
response = requests.options(
|
response = requests.options(
|
||||||
url,
|
url,
|
||||||
@@ -771,7 +779,12 @@ class InfraTest:
|
|||||||
allow_methods = response.headers.get("Access-Control-Allow-Methods", "")
|
allow_methods = response.headers.get("Access-Control-Allow-Methods", "")
|
||||||
if "PUT" not in allow_methods.upper():
|
if "PUT" not in allow_methods.upper():
|
||||||
raise AssertionError("CORS не разрешает PUT")
|
raise AssertionError("CORS не разрешает PUT")
|
||||||
return "CORS разрешает presigned PUT только с PUBLIC_WEB_URL"
|
allow_headers = response.headers.get("Access-Control-Allow-Headers", "")
|
||||||
|
if "content-type" not in allow_headers.lower():
|
||||||
|
raise AssertionError(
|
||||||
|
f"CORS не разрешает content-type: {allow_headers!r}"
|
||||||
|
)
|
||||||
|
return "CORS разрешает PUT с PUBLIC_WEB_URL через vHosted URL"
|
||||||
|
|
||||||
def _cleanup(self) -> None:
|
def _cleanup(self) -> None:
|
||||||
failures: list[str] = []
|
failures: list[str] = []
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ from contextlib import redirect_stdout
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from infratest import ConfigError, Reporter, Settings, as_bool, sanitize
|
from infratest import (
|
||||||
|
ConfigError,
|
||||||
|
Reporter,
|
||||||
|
Settings,
|
||||||
|
as_bool,
|
||||||
|
sanitize,
|
||||||
|
virtual_hosted_bucket_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def valid_env(ca_path: Path) -> str:
|
def valid_env(ca_path: Path) -> str:
|
||||||
@@ -102,6 +109,19 @@ class SettingsTests(unittest.TestCase):
|
|||||||
Settings.from_env_file(env_file)
|
Settings.from_env_file(env_file)
|
||||||
|
|
||||||
|
|
||||||
|
class UrlTests(unittest.TestCase):
|
||||||
|
def test_virtual_hosted_bucket_url(self) -> None:
|
||||||
|
url = virtual_hosted_bucket_url(
|
||||||
|
"https://s3.storage.selcloud.ru",
|
||||||
|
"han-chat-quarantine",
|
||||||
|
"infratest/key.bin",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
url,
|
||||||
|
"https://han-chat-quarantine.s3.storage.selcloud.ru/infratest/key.bin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SafetyTests(unittest.TestCase):
|
class SafetyTests(unittest.TestCase):
|
||||||
def test_sanitize_redacts_secret_and_url_query(self) -> None:
|
def test_sanitize_redacts_secret_and_url_query(self) -> None:
|
||||||
value = (
|
value = (
|
||||||
|
|||||||
Reference in New Issue
Block a user