Добавлен тест инфраструктуры

This commit is contained in:
mi
2026-07-14 19:41:25 +03:00
parent 4d8e856fd8
commit 6d1cb3d6d9
7 changed files with 1374 additions and 6 deletions
+145 -6
View File
@@ -1,14 +1,153 @@
# usefull commands # usefull commands
Убрать переносы строк: sed -i 's/\r$//' name-file.sh #sed -i 's/\r$//' /tmp/setup-vm.sh - Убрать переносы строк: sed -i 's/\r$//' name-file.sh
docker compose stop # мягко останавливает контейнеры (не удаляет) - docker compose stop # мягко останавливает контейнеры (не удаляет)
sudo shutdown -h now # выглючить ВМ - sudo shutdown -h now # выглючить ВМ
- Генерация паролей
- (со спец.символами) openssl rand -base64 32
- (без спец.символов) openssl rand -hex 16
- openssl rand -base64 16 | xclip -selection clipboard # Linux
Туннель до БД: ssh -i C:\Users\MI\.ssh\hansel -L 5432:10.105.90.166:5432 root@111.88.219.213 -N Туннель до БД: ssh -i C:\Users\MI\.ssh\hansel -L 5433:192.168.0.211:5433 root@135.106.164.58 -N
++ ssh -i C:\Users\MI\.ssh\hansel root@111.88.219.213 # Разворачиваем инфраструктуру в Селектел ч1
++ scp -i C:\Users\MI\.ssh\hansel -r "C:\Users\MI\Documents\Assistent\HAN_chat_specification\codebase\backend\deployment\scripts\setup-vm.sh" root@111.88.219.213:/tmp/setup-vm.sh ## Создание сети
## Создание групп безопасности для сети и для БД
Для сети открываем порты 80, 22, 443
Для БД открываем порты 5432, 5433 но только из CIDR (192.168.0.211\24)
## Создаем ВМ, подключаем к новой сети (через порт) и перезагружаем (либо руками вводить новые сетевые настройки - надо выбрать)
# Подключение к ВМ и первичный скрипт
++ ssh -i C:\Users\MI\.ssh\hansel root@135.106.164.58
++ scp -i C:\Users\MI\.ssh\hansel -r "C:\Users\MI\Documents\Assistent\HAN_chat_specification\codebase\backend\deployment\scripts\setup-vm.sh" root@135.106.164.58:/tmp/setup-vm.sh
chmod +x /tmp/setup-vm.sh chmod +x /tmp/setup-vm.sh
sudo /tmp/setup-vm.sh sudo /tmp/setup-vm.sh
# Скрипт создал деплой пользователя. Нужно Перенести туда публичный ключ и дать права на файл и папку
## Вставляем публичный ключ новой строкой Далее даем права
sudo nano /home/deploy/.ssh/authorized_keys
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh
## Пароль деплою для использования sudo
sudo passwd deploy
## После этого можно подключаться под деплой пользователем
ssh -i C:\Users\MI\.ssh\han_chat_deploy deploy@135.106.164.58
## GIT не работал. Проект пришлось копировать.
tar -C HAN_chat_specification/codebase/backend -czf han-chat-backend.tar.gz .
scp -i C:\Users\MI\.ssh\han_chat_deploy C:\Users\MI\Documents\Assistent\han-chat-backend.tar.gz deploy@135.106.164.58:/tmp/
## Дальше распаковка
cd /opt/han-chat/backend
tar -xzf /tmp/han-chat-backend.tar.gz
## Правим переносы строк и даем права на исполнение
find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} +
chmod +x scripts/validate-env deployment/scripts/*.sh redis/scripts/*.sh nginx/scripts/*.sh
# Разворачиваем инфраструктуру в Селектел ч2
## Создаем БД
В базе подключаем pgcrypto
### Копируем СА сертификат
Вариант Деплоя:
mkdir -p /opt/han-chat/backend/secrets/pg
scp -i C:\Users\MI\.ssh\han_chat_deploy -r C:\Users\MI\Documents\job\HAN_new_life\HANapp\Production\sertificates\CA.pem deploy@135.106.164.58:/opt/han-chat/backend/secrets/pg/ca.pem
chmod 644 /opt/han-chat/backend/secrets/pg/ca.pem
Вариант Селектела:
mkdir -p ~/.postgresql/
wget https://storage.dbaas.selcloud.ru/CA.pem -O ~/.postgresql/root.crt
chmod 0600 ~/.postgresql/root.crt
### Проверка подключения к Postgers в ВМ
nc -zv 192.168.0.211 5433
sudo apt update
sudo apt install postgresql-client
psql "host=master.ef54e3e4-ad3d-4b80-a6af-d63269e0895a.c.dbaas.selcloud.ru \
port=5432 \
dbname=han_chat \
user=dbAdmin \
sslmode=verify-ca"
### Заводим пользователей и схемы
Пользователей создаем через интерфейс селектела.
Под админом даем права на создание в БД:
GRANT CREATE ON DATABASE han_chat TO han_app;
GRANT CREATE ON DATABASE han_chat TO bitrix_local_app;
GRANT CREATE ON DATABASE han_chat TO bitrix_sync_user;
GRANT CREATE ON DATABASE han_chat TO message_safety_app;
GRANT CREATE ON DATABASE han_chat TO keycloak_user;
Схемы создаем от лица пользователей, заходя каждым из них в БД.
+ Запрещаем всем посторонним входить в схему han_app и др.
CREATE SCHEMA IF NOT EXISTS han_app;
REVOKE ALL ON SCHEMA han_app FROM PUBLIC;
CREATE SCHEMA IF NOT EXISTS bitrix_local;
REVOKE ALL ON SCHEMA bitrix_local FROM PUBLIC;
CREATE SCHEMA IF NOT EXISTS bitrix_sync;
REVOKE ALL ON SCHEMA bitrix_sync FROM PUBLIC;
CREATE SCHEMA IF NOT EXISTS message_safety;
REVOKE ALL ON SCHEMA message_safety FROM PUBLIC;
CREATE SCHEMA IF NOT EXISTS keycloak;
REVOKE ALL ON SCHEMA keycloak FROM PUBLIC;
После реализации bitrix_sync (проверить, вероятно не на все таблицы права нужны):
-- Даем право на чтение (SELECT) всех СУЩЕСТВУЮЩИХ таблиц в схеме
GRANT SELECT, INSERT ON ALL TABLES IN SCHEMA han_app TO bitrix_sync_user;
-- Настраиваем права по умолчанию для новых таблиц
ALTER DEFAULT PRIVILEGES IN SCHEMA han_app
GRANT SELECT ON TABLES TO bitrix_sync_user;
-- Если нужно дать право на чтение и для новых последовательностей (sequences):
ALTER DEFAULT PRIVILEGES IN SCHEMA han_app
GRANT USAGE, SELECT ON SEQUENCES TO bitrix_sync_user;
## Заводим S3 хранилища:
Создаем 2 сервисных пользователя и заводим им ключи:
- API backend SELECTEL_S3_ACCESS_KEY / SELECTEL_S3_SECRET_KEY
- Message Safety SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY / SELECTEL_S3_QUARANTINE_READ_SECRET_KEY
Создаем 3 приватных бакета
- han-chat-quarantine (политика доступа API backend RW, Message Safety R)
- han-chat-attachments (политика доступа API backend RW)
- han-chat-documents (политика доступа API backend RW)
RW:
ListBucket
GetBucketLocation
PutObject
GetObject
DeleteObject
ListBucketMultipartUploads
ListMultipartUploadParts
AbortMultipartUpload
R:
ListBucket
GetBucketLocation
GetObject
ListBucketMultipartUploads
ListMultipartUploadParts
Политика CORS:
Allowed origin: https://chat.example.ru
Methods: POST, PUT, GET, HEAD
Headers: Content-Type, x-amz-*
Expose headers: ETag
+53
View File
@@ -0,0 +1,53 @@
# Скопируйте в .env, заполните секреты и выполните:
# chmod 600 .env
# python3 infratest.py --mode full
# Managed PostgreSQL. Указывайте адрес из ВМ и CA-файл на самой ВМ.
HAN_PG_HOST=managed-pg.private.example
HAN_PG_PORT=6432
HAN_PG_DATABASE=han_chat
HAN_PG_SSLMODE=verify-full
HAN_PG_SSLROOTCERT=/opt/han-chat/secrets/pg/ca.pem
# Имена ролей и схем совпадают с контрактом HAN Chat.
HAN_PG_USER_HAN_APP=han_app
HAN_PG_SCHEMA_HAN_APP=han_app
HAN_PG_PASSWORD_HAN_APP=
HAN_PG_USER_BITRIX=bitrix_local_app
HAN_PG_SCHEMA_BITRIX=bitrix_local
HAN_PG_PASSWORD_BITRIX=
HAN_PG_USER_BITRIX_SYNC=bitrix_sync_user
HAN_PG_SCHEMA_BITRIX_SYNC=bitrix_sync
HAN_PG_PASSWORD_BITRIX_SYNC=
HAN_PG_USER_MESSAGE_SAFETY=message_safety_app
HAN_PG_SCHEMA_MESSAGE_SAFETY=message_safety
HAN_PG_PASSWORD_MESSAGE_SAFETY=
HAN_PG_USER_KEYCLOAK=keycloak_user
HAN_PG_SCHEMA_KEYCLOAK=keycloak
HAN_PG_PASSWORD_KEYCLOAK=
# Selectel S3: основной RW-ключ API backend.
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
# Не задавайте регион, если приложение также использует значение SDK по умолчанию.
SELECTEL_S3_REGION=
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments
SELECTEL_S3_BUCKET_DOCUMENTS=han-chat-documents
SELECTEL_S3_ACCESS_KEY=
SELECTEL_S3_SECRET_KEY=
# Отдельный read-only ключ message-safety: R только для quarantine.
SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY=
SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=
# Точный origin веб-приложения для CORS-проверки.
PUBLIC_WEB_URL=https://chat.example.ru
# Проверки после миграций Alembic включайте только после deployment/scripts/migrate.sh.
INFRATEST_CHECK_MIGRATIONS=false
INFRATEST_CHECK_CORS=true
INFRATEST_TIMEOUT_SECONDS=15
+6
View File
@@ -0,0 +1,6 @@
.env
.venv/
.ruff_cache/
__pycache__/
*.pyc
report*.json
+180
View File
@@ -0,0 +1,180 @@
# Инфраструктурный тест HAN Chat
Автономный тест запускается непосредственно на ВМ и проверяет managed PostgreSQL и
три приватных Selectel S3-бакета в соответствии с контрактами HAN Chat. Для запуска
приложение и Docker Compose не требуются.
Тест не создаёт постоянных данных:
- PostgreSQL DDL/DML выполняются в транзакции и завершаются `ROLLBACK`;
- S3-объекты создаются только под уникальным префиксом `infratest/<uuid>/`;
- все S3-объекты удаляются в блоке очистки даже при ошибке одной из проверок.
## Что проверяется
### PostgreSQL
- подключение пяти сервисных ролей с TLS `verify-full`;
- `current_user`, `search_path`, наличие своей схемы и отсутствие `USAGE` на четыре
чужие схемы;
- `CREATE TABLE`, `INSERT`, `SELECT` и фактическое отсутствие таблицы после
`ROLLBACK`;
- расширение `pgcrypto`;
- опционально — ключевые таблицы и Alembic revision после миграций.
Проверяются роли:
- `han_app``han_app`;
- `bitrix_local_app``bitrix_local`;
- `bitrix_sync_user``bitrix_sync`;
- `message_safety_app``message_safety`;
- `keycloak_user``keycloak`.
### S3
- `HeadBucket` для quarantine, attachments и documents;
- прямые `PutObject`, `HeadObject`, `GetObject`, `ListObjectsV2` с ВМ;
- presigned PUT и GET с побайтовой проверкой содержимого;
- проектный переход `CopyObject` из quarantine в attachments с удалением
исходного объекта;
- запрет анонимного GET;
- CORS preflight для точного `PUBLIC_WEB_URL`;
- отдельный ключ message-safety: чтение/list только quarantine, запрет записи,
удаления и доступа к attachments/documents.
Multipart API не проверяется: текущая реализация HAN Chat использует single PUT.
Прикладные сценарии авторизации, создания диалога и отправки сообщения также не
входят в этот инструмент — это E2E приложения, а не проверка инфраструктуры.
## Подготовка на ВМ
Рекомендуется скопировать всю папку в отдельный каталог:
```bash
mkdir -p /opt/han-chat/infratest
cd /opt/han-chat/infratest
```
Установите Python 3.10+ и создайте изолированное окружение:
```bash
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install --upgrade pip
python3 -m pip install -r requirements.txt
```
Создайте настройки:
```bash
cp .env.example .env
chmod 600 .env
nano .env
```
В `.env` нужны сервисные пароли PostgreSQL, основной S3 RW-ключ и отдельный
quarantine read-only ключ. Административный пароль PostgreSQL не используется.
`HAN_PG_SSLROOTCERT` должен указывать на CA-файл по пути, доступному из процесса
на ВМ, а не на контейнерный путь `/run/secrets/pg-ca.pem`.
Не передавайте секреты аргументами командной строки и не прикладывайте `.env` к
отчётам. Файл исключён через `.gitignore`.
## Запуск
Полная самочищающаяся проверка:
```bash
cd /opt/han-chat/infratest
. .venv/bin/activate
python3 infratest.py --mode full
```
Проверка без DDL и записи в S3:
```bash
python3 infratest.py --mode readonly
```
Другой файл настроек и JSON-отчёт:
```bash
python3 infratest.py \
--env-file /secure/path/infratest.env \
--mode full \
--json-report report.json
```
Скрипт возвращает:
- `0` — нет проваленных проверок;
- `1` — одна или несколько инфраструктурных проверок завершились ошибкой;
- `2` — неверная конфигурация или отсутствуют Python-зависимости.
В консоль и JSON не выводятся пароли, access keys и query-параметры presigned URL.
## Запуск до и после миграций
До запуска Alembic оставьте:
```dotenv
INFRATEST_CHECK_MIGRATIONS=false
```
После успешного `deployment/scripts/migrate.sh` измените значение на:
```dotenv
INFRATEST_CHECK_MIGRATIONS=true
```
В этом режиме дополнительно проверяются ключевые таблицы схем `han_app`,
`bitrix_local`, `bitrix_sync` и ожидаемая revision `0001_initial` для `han_app`.
## Ожидаемая IAM-модель
Основной `SELECTEL_S3_ACCESS_KEY` должен иметь:
- RW на quarantine, attachments и documents;
- `ListBucket`, `GetBucketLocation`, `PutObject`, `GetObject`, `DeleteObject`;
- `CopyObject` обеспечивается правами чтения источника и записи назначения.
Ключ `SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY` должен иметь только:
- `ListBucket`, `GetBucketLocation`, `GetObject` для quarantine;
- никаких прав на Put/Delete;
- никаких прав на attachments и documents.
Если IAM-политика ограничивает основной ключ другими object prefixes, разрешите
служебный префикс `infratest/*` либо запускайте только `--mode readonly`.
## CORS
При `INFRATEST_CHECK_CORS=true` quarantine должен отвечать на preflight:
- origin — точное значение `PUBLIC_WEB_URL`, без wildcard;
- method — `PUT`;
- headers — `Content-Type`, `x-amz-*`.
Чтобы временно исключить CORS из диагностики:
```dotenv
INFRATEST_CHECK_CORS=false
```
## Интерпретация результата
- `PASS` — контракт подтверждён реальной операцией;
- `FAIL` — контракт нарушен или ресурс недоступен;
- `SKIP` — проверка отключена режимом или настройкой.
При `FAIL` сначала проверьте сетевые ACL ВМ, CA/hostname PostgreSQL, GRANT и
`search_path`, затем IAM/CORS бакетов. После аварийного прерывания процесса можно
найти остатки только под префиксом `infratest/`; удалять другие ключи не требуется.
## Локальные unit-тесты
Они не обращаются к PostgreSQL или S3:
```bash
python3 -m unittest -v test_infratest.py
```
+844
View File
@@ -0,0 +1,844 @@
#!/usr/bin/env python3
"""Самоочищающийся инфраструктурный тест PostgreSQL и S3 для HAN Chat."""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlsplit, urlunsplit
try:
import boto3
import psycopg
import requests
from botocore.config import Config as BotoConfig
from botocore.exceptions import ClientError
from dotenv import dotenv_values
from psycopg import sql
except ImportError as exc: # pragma: no cover - понятная ошибка до запуска тестов
print(
f"Не установлена зависимость {exc.name!r}. "
"Выполните: python3 -m pip install -r requirements.txt",
file=sys.stderr,
)
raise SystemExit(2) from exc
URL_PATTERN = re.compile(r"https?://[^\s]+")
EXPECTED_ROLES = (
("han_app", "han_app", "HAN_PG_PASSWORD_HAN_APP"),
("bitrix_local_app", "bitrix_local", "HAN_PG_PASSWORD_BITRIX"),
("bitrix_sync_user", "bitrix_sync", "HAN_PG_PASSWORD_BITRIX_SYNC"),
("message_safety_app", "message_safety", "HAN_PG_PASSWORD_MESSAGE_SAFETY"),
("keycloak_user", "keycloak", "HAN_PG_PASSWORD_KEYCLOAK"),
)
EXPECTED_TABLES = {
"han_app": (
"alembic_version",
"app_settings",
"dialogs",
"messages",
"message_attachments",
"documents",
"sync_queue",
),
"bitrix_local": (
"alembic_version",
"portal_installations",
"dialog_sessions",
"inbox_events",
"outbound_messages",
),
"bitrix_sync": ("alembic_version",),
}
class ConfigError(ValueError):
"""Ошибка пользовательской конфигурации."""
def as_bool(value: str | None, default: bool = False) -> bool:
if value is None or not value.strip():
return default
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise ConfigError(f"Ожидалось логическое значение, получено: {value!r}")
def sanitize(value: Any) -> str:
"""Удаляет секреты и query-параметры URL из диагностического текста."""
text = str(value)
text = URL_PATTERN.sub(
lambda match: urlunsplit(
(*urlsplit(match.group(0))[:3], "", "")
),
text,
)
text = re.sub(
r"(?i)\b(password|secret|access[_-]?key|authorization|token|dsn)"
r"\s*[:=]\s*[^\s,;]+",
lambda match: f"{match.group(1)}=<redacted>",
text,
)
return text[:1000]
def require(values: dict[str, str], name: str) -> str:
value = values.get(name, "").strip()
if not value:
raise ConfigError(f"Не задана обязательная переменная {name}")
return value
@dataclass(frozen=True)
class PgRole:
user: str
schema: str
password: str = field(repr=False)
@dataclass(frozen=True)
class Settings:
pg_host: str
pg_port: int
pg_database: str
pg_sslmode: str
pg_sslrootcert: str
pg_roles: tuple[PgRole, ...]
s3_endpoint: str
s3_region: str | None
buckets: dict[str, str]
s3_access_key: str = field(repr=False)
s3_secret_key: str = field(repr=False)
s3_read_access_key: str = field(repr=False)
s3_read_secret_key: str = field(repr=False)
public_web_url: str | None
check_cors: bool
check_migrations: bool
timeout_seconds: int
@classmethod
def from_env_file(cls, path: Path) -> "Settings":
if not path.is_file():
raise ConfigError(f"Файл настроек не найден: {path}")
raw = dotenv_values(path)
values = {
key: str(value)
for key, value in raw.items()
if value is not None
}
# Явно экспортированные переменные имеют приоритет над файлом.
values.update({key: value for key, value in os.environ.items() if key in values})
sslmode = values.get("HAN_PG_SSLMODE", "verify-full").strip()
if sslmode != "verify-full":
raise ConfigError("HAN_PG_SSLMODE должен быть verify-full")
ca_path = require(values, "HAN_PG_SSLROOTCERT")
if not Path(ca_path).is_file():
raise ConfigError(f"CA-файл PostgreSQL не найден: {ca_path}")
endpoint = require(values, "SELECTEL_S3_ENDPOINT_URL")
parsed_endpoint = urlsplit(endpoint)
if parsed_endpoint.scheme != "https" or not parsed_endpoint.netloc:
raise ConfigError("SELECTEL_S3_ENDPOINT_URL должен быть корректным HTTPS URL")
roles = tuple(
PgRole(
values.get(f"HAN_PG_USER_{env_suffix}", default_user).strip(),
values.get(f"HAN_PG_SCHEMA_{env_suffix}", default_schema).strip(),
require(values, password_env),
)
for default_user, default_schema, password_env in EXPECTED_ROLES
for env_suffix in (
{
"han_app": "HAN_APP",
"bitrix_local": "BITRIX",
"bitrix_sync": "BITRIX_SYNC",
"message_safety": "MESSAGE_SAFETY",
"keycloak": "KEYCLOAK",
}[default_schema],
)
)
timeout = int(values.get("INFRATEST_TIMEOUT_SECONDS", "15"))
if timeout < 1 or timeout > 300:
raise ConfigError("INFRATEST_TIMEOUT_SECONDS должен быть от 1 до 300")
public_web_url = values.get("PUBLIC_WEB_URL", "").strip() or None
check_cors = as_bool(values.get("INFRATEST_CHECK_CORS"), True)
if check_cors and not public_web_url:
raise ConfigError("Для CORS-проверки задайте PUBLIC_WEB_URL")
return cls(
pg_host=require(values, "HAN_PG_HOST"),
pg_port=int(values.get("HAN_PG_PORT", "5432")),
pg_database=require(values, "HAN_PG_DATABASE"),
pg_sslmode=sslmode,
pg_sslrootcert=ca_path,
pg_roles=roles,
s3_endpoint=endpoint.rstrip("/"),
s3_region=values.get("SELECTEL_S3_REGION", "").strip() or None,
buckets={
"quarantine": require(values, "SELECTEL_S3_BUCKET_QUARANTINE"),
"attachments": require(values, "SELECTEL_S3_BUCKET_ATTACHMENTS"),
"documents": require(values, "SELECTEL_S3_BUCKET_DOCUMENTS"),
},
s3_access_key=require(values, "SELECTEL_S3_ACCESS_KEY"),
s3_secret_key=require(values, "SELECTEL_S3_SECRET_KEY"),
s3_read_access_key=require(
values, "SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY"
),
s3_read_secret_key=require(
values, "SELECTEL_S3_QUARANTINE_READ_SECRET_KEY"
),
public_web_url=public_web_url,
check_cors=check_cors,
check_migrations=as_bool(
values.get("INFRATEST_CHECK_MIGRATIONS"), False
),
timeout_seconds=timeout,
)
@dataclass(frozen=True)
class Result:
check: str
status: str
detail: str
class Reporter:
def __init__(self) -> None:
self.results: list[Result] = []
def add(self, check: str, status: str, detail: Any) -> None:
result = Result(check, status, sanitize(detail))
self.results.append(result)
print(f"[{status:4}] {check}: {result.detail}")
def run(
self,
check: str,
operation: Callable[[], Any],
success_detail: str = "OK",
) -> bool:
try:
detail = operation()
except Exception as exc: # noqa: BLE001 - каждая проверка должна продолжить отчёт
self.add(check, "FAIL", f"{type(exc).__name__}: {exc}")
return False
self.add(check, "PASS", success_detail if detail is None else detail)
return True
def skip(self, check: str, detail: str) -> None:
self.add(check, "SKIP", detail)
@property
def exit_code(self) -> int:
return 1 if any(item.status == "FAIL" for item in self.results) else 0
def write_json(self, path: Path) -> None:
path.write_text(
json.dumps(
[item.__dict__ for item in self.results],
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
)
class InfraTest:
def __init__(self, settings: Settings, reporter: Reporter, mode: str) -> None:
self.settings = settings
self.reporter = reporter
self.mode = mode
self.run_id = uuid.uuid4().hex
self.prefix = f"infratest/{self.run_id}"
self.payload = (
b"HAN Chat infrastructure test\n"
+ self.run_id.encode("ascii")
+ b"\n"
)
self.api_s3 = self._s3_client(
settings.s3_access_key, settings.s3_secret_key
)
self.read_s3 = self._s3_client(
settings.s3_read_access_key, settings.s3_read_secret_key
)
self.created_objects: set[tuple[str, str]] = set()
def _s3_client(self, access_key: str, secret_key: str):
kwargs: dict[str, Any] = {
"service_name": "s3",
"endpoint_url": self.settings.s3_endpoint,
"aws_access_key_id": access_key,
"aws_secret_access_key": secret_key,
"config": BotoConfig(
connect_timeout=self.settings.timeout_seconds,
read_timeout=self.settings.timeout_seconds,
retries={"max_attempts": 2},
),
}
if self.settings.s3_region:
kwargs["region_name"] = self.settings.s3_region
return boto3.client(**kwargs)
def _pg_connect(self, role: PgRole):
return psycopg.connect(
host=self.settings.pg_host,
port=self.settings.pg_port,
dbname=self.settings.pg_database,
user=role.user,
password=role.password,
sslmode=self.settings.pg_sslmode,
sslrootcert=self.settings.pg_sslrootcert,
connect_timeout=self.settings.timeout_seconds,
application_name=f"han-infratest-{self.run_id[:8]}",
)
def run(self) -> None:
self.run_postgres()
self.run_s3()
def run_postgres(self) -> None:
print("\nPostgreSQL")
schemas = tuple(role.schema for role in self.settings.pg_roles)
for role in self.settings.pg_roles:
connection_ok = self.reporter.run(
f"postgres.{role.user}.connect_tls",
lambda role=role: self._check_pg_connection(role),
)
if not connection_ok:
continue
self.reporter.run(
f"postgres.{role.user}.schema_isolation",
lambda role=role: self._check_schema_isolation(role, schemas),
)
if self.mode == "full":
self.reporter.run(
f"postgres.{role.user}.ddl_dml_rollback",
lambda role=role: self._check_pg_write(role),
)
else:
self.reporter.skip(
f"postgres.{role.user}.ddl_dml_rollback",
"режим readonly",
)
self.reporter.run("postgres.pgcrypto", self._check_pgcrypto)
if self.settings.check_migrations:
self.reporter.run("postgres.migrations", self._check_migrations)
else:
self.reporter.skip(
"postgres.migrations",
"INFRATEST_CHECK_MIGRATIONS=false",
)
def _check_pg_connection(self, role: PgRole) -> str:
with self._pg_connect(role) as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT current_user, current_setting('search_path'),
EXISTS (
SELECT 1 FROM pg_stat_ssl
WHERE pid = pg_backend_pid() AND ssl
)
"""
)
current_user, search_path, tls_active = cur.fetchone()
if current_user != role.user:
raise AssertionError(
f"current_user={current_user!r}, ожидался {role.user!r}"
)
if role.schema not in search_path.split(","):
raise AssertionError(
f"search_path={search_path!r}, ожидалась {role.schema!r}"
)
if not tls_active:
raise AssertionError("соединение установлено без TLS")
return f"TLS active, search_path={search_path}"
def _check_schema_isolation(
self, role: PgRole, all_schemas: tuple[str, ...]
) -> str:
with self._pg_connect(role) as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.schemata
WHERE schema_name = %s
), has_schema_privilege(current_user, %s, 'USAGE')
""",
(role.schema, role.schema),
)
schema_exists, own_usage = cur.fetchone()
if not schema_exists or not own_usage:
raise AssertionError(
f"нет схемы или USAGE для {role.schema}"
)
unexpected = []
for other in all_schemas:
if other == role.schema:
continue
cur.execute(
"SELECT has_schema_privilege(current_user, %s, 'USAGE')",
(other,),
)
if cur.fetchone()[0]:
unexpected.append(other)
if unexpected:
raise AssertionError(
"обнаружен USAGE на чужие схемы: " + ", ".join(unexpected)
)
return f"доступ только к схеме {role.schema}"
def _check_pg_write(self, role: PgRole) -> str:
table_name = f"_infratest_{self.run_id}"
with self._pg_connect(role) as conn:
try:
with conn.cursor() as cur:
cur.execute(
sql.SQL("CREATE TABLE {}.{} (id integer PRIMARY KEY, value text)")
.format(sql.Identifier(role.schema), sql.Identifier(table_name))
)
cur.execute(
sql.SQL("INSERT INTO {}.{} VALUES (%s, %s)").format(
sql.Identifier(role.schema), sql.Identifier(table_name)
),
(1, "ok"),
)
cur.execute(
sql.SQL("SELECT value FROM {}.{} WHERE id = %s").format(
sql.Identifier(role.schema), sql.Identifier(table_name)
),
(1,),
)
if cur.fetchone()[0] != "ok":
raise AssertionError("прочитано неожиданное значение")
finally:
conn.rollback()
with conn.cursor() as cur:
cur.execute("SELECT to_regclass(%s)", (f"{role.schema}.{table_name}",))
if cur.fetchone()[0] is not None:
raise AssertionError("таблица сохранилась после ROLLBACK")
return "CREATE/INSERT/SELECT успешны, DDL откачен"
def _check_pgcrypto(self) -> str:
role = next(item for item in self.settings.pg_roles if item.schema == "han_app")
with self._pg_connect(role) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname='pgcrypto')"
)
if not cur.fetchone()[0]:
raise AssertionError("расширение pgcrypto не установлено")
return "расширение pgcrypto установлено"
def _check_migrations(self) -> str:
role_by_schema = {role.schema: role for role in self.settings.pg_roles}
missing: list[str] = []
revisions: dict[str, str] = {}
for schema, tables in EXPECTED_TABLES.items():
with self._pg_connect(role_by_schema[schema]) as conn:
with conn.cursor() as cur:
for table in tables:
cur.execute("SELECT to_regclass(%s)", (f"{schema}.{table}",))
if cur.fetchone()[0] is None:
missing.append(f"{schema}.{table}")
cur.execute(
sql.SQL("SELECT version_num FROM {}.alembic_version LIMIT 1")
.format(sql.Identifier(schema))
)
row = cur.fetchone()
revisions[schema] = row[0] if row else "<empty>"
if missing:
raise AssertionError("нет таблиц: " + ", ".join(missing))
if revisions.get("han_app") != "0001_initial":
raise AssertionError(
f"han_app revision={revisions.get('han_app')!r}, ожидался '0001_initial'"
)
return "таблицы и Alembic revisions присутствуют"
def run_s3(self) -> None:
print("\nSelectel S3")
reachable: dict[str, bool] = {}
for logical_name, bucket in self.settings.buckets.items():
reachable[logical_name] = self.reporter.run(
f"s3.{logical_name}.head_bucket",
lambda bucket=bucket: self.api_s3.head_bucket(Bucket=bucket),
f"бакет {bucket} доступен",
)
self.reporter.run(
"s3.quarantine_read_key.boundaries",
self._check_read_key_bucket_boundaries,
)
if self.mode != "full":
for name in (
"direct_vm_operations",
"presigned_put_get",
"copy_promote",
"cors",
"anonymous_access",
"quarantine_read_key.object_permissions",
):
self.reporter.skip(f"s3.{name}", "режим readonly")
return
if not all(reachable.values()):
self.reporter.add(
"s3.full_operations",
"FAIL",
"полные проверки невозможны: не все бакеты доступны",
)
return
try:
direct_keys: dict[str, str] = {}
for logical_name, bucket in self.settings.buckets.items():
key = f"{self.prefix}/direct-{logical_name}.bin"
direct_keys[logical_name] = key
self.reporter.run(
f"s3.{logical_name}.direct_vm_operations",
lambda bucket=bucket, key=key: self._check_direct_object(
bucket, key
),
)
presigned_key = f"{self.prefix}/presigned.bin"
presigned_ok = self.reporter.run(
"s3.quarantine.presigned_put_get",
lambda: self._check_presigned(presigned_key),
)
if presigned_ok:
promoted_key = f"{self.prefix}/promoted.bin"
self.reporter.run(
"s3.quarantine_to_attachments.copy_promote",
lambda: self._check_promote(presigned_key, promoted_key),
)
privacy_key = direct_keys["documents"]
self.reporter.run(
"s3.documents.anonymous_access_denied",
lambda: self._check_anonymous_denied(
self.settings.buckets["documents"], privacy_key
),
)
self.reporter.run(
"s3.quarantine_read_key.object_permissions",
lambda: self._check_read_key_object_permissions(
direct_keys["quarantine"],
direct_keys["attachments"],
direct_keys["documents"],
),
)
if self.settings.check_cors:
cors_key = f"{self.prefix}/cors.bin"
self.reporter.run(
"s3.quarantine.cors_preflight",
lambda: self._check_cors(cors_key),
)
else:
self.reporter.skip(
"s3.quarantine.cors_preflight",
"INFRATEST_CHECK_CORS=false",
)
finally:
self._cleanup()
def _check_direct_object(self, bucket: str, key: str) -> str:
self.api_s3.put_object(
Bucket=bucket,
Key=key,
Body=self.payload,
ContentType="application/octet-stream",
Metadata={"infratest-run": self.run_id},
)
self.created_objects.add((bucket, key))
head = self.api_s3.head_object(Bucket=bucket, Key=key)
if head["ContentLength"] != len(self.payload):
raise AssertionError("HeadObject вернул неверный размер")
response = self.api_s3.get_object(Bucket=bucket, Key=key)
if response["Body"].read() != self.payload:
raise AssertionError("GetObject вернул другие байты")
listed = self.api_s3.list_objects_v2(
Bucket=bucket, Prefix=key, MaxKeys=2
).get("Contents", [])
if not any(item["Key"] == key for item in listed):
raise AssertionError("ListObjectsV2 не вернул тестовый объект")
return "Put/Head/Get/List с ВМ успешны"
def _check_presigned(self, key: str) -> str:
bucket = self.settings.buckets["quarantine"]
put_url = self.api_s3.generate_presigned_url(
"put_object",
Params={
"Bucket": bucket,
"Key": key,
"ContentType": "application/octet-stream",
},
ExpiresIn=300,
)
response = requests.put(
put_url,
data=self.payload,
headers={"Content-Type": "application/octet-stream"},
timeout=self.settings.timeout_seconds,
)
response.raise_for_status()
self.created_objects.add((bucket, key))
self.api_s3.head_object(Bucket=bucket, Key=key)
get_url = self.api_s3.generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": key},
ExpiresIn=300,
)
downloaded = requests.get(
get_url, timeout=self.settings.timeout_seconds
)
downloaded.raise_for_status()
if downloaded.content != self.payload:
raise AssertionError("presigned GET вернул другие байты")
return "presigned PUT/GET успешны, содержимое совпадает"
def _check_promote(self, source_key: str, destination_key: str) -> str:
quarantine = self.settings.buckets["quarantine"]
attachments = self.settings.buckets["attachments"]
self.api_s3.copy_object(
Bucket=attachments,
Key=destination_key,
CopySource={"Bucket": quarantine, "Key": source_key},
)
self.created_objects.add((attachments, destination_key))
copied = self.api_s3.get_object(
Bucket=attachments, Key=destination_key
)["Body"].read()
if copied != self.payload:
raise AssertionError("скопированный объект повреждён")
self.api_s3.delete_object(Bucket=quarantine, Key=source_key)
self.created_objects.discard((quarantine, source_key))
try:
self.api_s3.head_object(Bucket=quarantine, Key=source_key)
except ClientError as exc:
if exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode") != 404:
raise
else:
raise AssertionError("исходный quarantine-объект не удалён")
return "CopyObject успешен, исходный объект удалён"
def _check_anonymous_denied(self, bucket: str, key: str) -> str:
signed_url = self.api_s3.generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": key},
ExpiresIn=300,
)
parsed = urlsplit(signed_url)
unsigned_url = urlunsplit(
(parsed.scheme, parsed.netloc, parsed.path, "", "")
)
response = requests.get(
unsigned_url,
allow_redirects=False,
timeout=self.settings.timeout_seconds,
)
if response.status_code not in {401, 403}:
raise AssertionError(
f"анонимный GET вернул HTTP {response.status_code}, ожидался 401/403"
)
return f"анонимный GET запрещён (HTTP {response.status_code})"
@staticmethod
def _assert_denied(operation: Callable[[], Any], description: str) -> None:
try:
operation()
except ClientError as exc:
status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
code = exc.response.get("Error", {}).get("Code", "")
if status in {401, 403} or code in {
"AccessDenied",
"AllAccessDisabled",
"InvalidAccessKeyId",
}:
return
raise
raise AssertionError(f"операция неожиданно разрешена: {description}")
def _check_read_key_bucket_boundaries(self) -> str:
quarantine = self.settings.buckets["quarantine"]
self.read_s3.head_bucket(Bucket=quarantine)
self.read_s3.list_objects_v2(Bucket=quarantine, MaxKeys=1)
for logical_name in ("attachments", "documents"):
bucket = self.settings.buckets[logical_name]
self._assert_denied(
lambda bucket=bucket: self.read_s3.list_objects_v2(
Bucket=bucket, MaxKeys=1
),
f"read-key ListBucket {logical_name}",
)
return "read-key видит только quarantine"
def _check_read_key_object_permissions(
self,
quarantine_key: str,
attachments_key: str,
documents_key: str,
) -> str:
quarantine = self.settings.buckets["quarantine"]
attachments = self.settings.buckets["attachments"]
documents = self.settings.buckets["documents"]
response = self.read_s3.get_object(
Bucket=quarantine, Key=quarantine_key
)
if response["Body"].read() != self.payload:
raise AssertionError("read-key получил повреждённые данные")
denied_put_key = f"{self.prefix}/read-key-must-not-put.bin"
try:
self._assert_denied(
lambda: self.read_s3.put_object(
Bucket=quarantine, Key=denied_put_key, Body=self.payload
),
"read-key PutObject quarantine",
)
finally:
# Если политика ошибочно разрешила PUT, удалить объект API-ключом.
try:
self.api_s3.delete_object(
Bucket=quarantine, Key=denied_put_key
)
except Exception: # noqa: BLE001 - основная очистка будет продолжена
pass
self._assert_denied(
lambda: self.read_s3.delete_object(
Bucket=quarantine, Key=quarantine_key
),
"read-key DeleteObject quarantine",
)
self._assert_denied(
lambda: self.read_s3.get_object(
Bucket=attachments, Key=attachments_key
),
"read-key GetObject attachments",
)
self._assert_denied(
lambda: self.read_s3.get_object(
Bucket=documents, Key=documents_key
),
"read-key GetObject documents",
)
return "GET quarantine разрешён; запись, удаление и другие бакеты запрещены"
def _check_cors(self, key: str) -> str:
url = self.api_s3.generate_presigned_url(
"put_object",
Params={
"Bucket": self.settings.buckets["quarantine"],
"Key": key,
"ContentType": "application/octet-stream",
},
ExpiresIn=300,
)
response = requests.options(
url,
headers={
"Origin": self.settings.public_web_url or "",
"Access-Control-Request-Method": "PUT",
"Access-Control-Request-Headers": "content-type,x-amz-meta-infratest",
},
timeout=self.settings.timeout_seconds,
)
if response.status_code not in {200, 204}:
raise AssertionError(f"preflight вернул HTTP {response.status_code}")
allow_origin = response.headers.get("Access-Control-Allow-Origin")
if allow_origin != self.settings.public_web_url:
raise AssertionError(
f"Access-Control-Allow-Origin={allow_origin!r}, "
f"ожидался точный PUBLIC_WEB_URL"
)
allow_methods = response.headers.get("Access-Control-Allow-Methods", "")
if "PUT" not in allow_methods.upper():
raise AssertionError("CORS не разрешает PUT")
return "CORS разрешает presigned PUT только с PUBLIC_WEB_URL"
def _cleanup(self) -> None:
failures: list[str] = []
for bucket, key in sorted(self.created_objects):
try:
self.api_s3.delete_object(Bucket=bucket, Key=key)
except Exception as exc: # noqa: BLE001 - удалить остальные объекты
failures.append(f"{bucket}/{key}: {sanitize(exc)}")
if failures:
self.reporter.add(
"s3.cleanup",
"FAIL",
"не удалены тестовые объекты: " + "; ".join(failures),
)
else:
self.reporter.add(
"s3.cleanup",
"PASS",
f"удалены все объекты префикса {self.prefix}",
)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Проверка PostgreSQL и Selectel S3 для HAN Chat"
)
parser.add_argument(
"--env-file",
type=Path,
default=Path(__file__).with_name(".env"),
help="путь к .env (по умолчанию .env рядом со скриптом)",
)
parser.add_argument(
"--mode",
choices=("readonly", "full"),
default="full",
help="full выполняет самочищающиеся DDL/S3 операции",
)
parser.add_argument(
"--json-report",
type=Path,
help="сохранить обезличенный JSON-отчёт",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
settings = Settings.from_env_file(args.env_file.resolve())
except (ConfigError, ValueError) as exc:
print(f"Ошибка конфигурации: {sanitize(exc)}", file=sys.stderr)
return 2
reporter = Reporter()
test = InfraTest(settings, reporter, args.mode)
print(f"HAN Chat infrastructure test: mode={args.mode}, run={test.run_id[:8]}")
test.run()
if args.json_report:
reporter.write_json(args.json_report)
passed = sum(item.status == "PASS" for item in reporter.results)
failed = sum(item.status == "FAIL" for item in reporter.results)
skipped = sum(item.status == "SKIP" for item in reporter.results)
print(f"\nИтог: PASS={passed}, FAIL={failed}, SKIP={skipped}")
return reporter.exit_code
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -0,0 +1,4 @@
boto3
psycopg[binary]
python-dotenv
requests
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
import io
import json
import os
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
from infratest import ConfigError, Reporter, Settings, as_bool, sanitize
def valid_env(ca_path: Path) -> str:
return f"""
HAN_PG_HOST=db.example.test
HAN_PG_PORT=6432
HAN_PG_DATABASE=han_chat
HAN_PG_SSLMODE=verify-full
HAN_PG_SSLROOTCERT={ca_path}
HAN_PG_PASSWORD_HAN_APP=han-password
HAN_PG_PASSWORD_BITRIX=bitrix-password
HAN_PG_PASSWORD_BITRIX_SYNC=sync-password
HAN_PG_PASSWORD_MESSAGE_SAFETY=safety-password
HAN_PG_PASSWORD_KEYCLOAK=keycloak-password
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
SELECTEL_S3_BUCKET_QUARANTINE=quarantine
SELECTEL_S3_BUCKET_ATTACHMENTS=attachments
SELECTEL_S3_BUCKET_DOCUMENTS=documents
SELECTEL_S3_ACCESS_KEY=api-access
SELECTEL_S3_SECRET_KEY=api-secret
SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY=read-access
SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=read-secret
PUBLIC_WEB_URL=https://chat.example.test
INFRATEST_CHECK_CORS=true
INFRATEST_CHECK_MIGRATIONS=false
INFRATEST_TIMEOUT_SECONDS=10
""".strip()
class SettingsTests(unittest.TestCase):
def test_loads_complete_configuration_and_default_roles(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
ca = root / "ca.pem"
ca.write_text("test ca", encoding="utf-8")
env_file = root / ".env"
env_file.write_text(valid_env(ca), encoding="utf-8")
with patch.dict(os.environ, {}, clear=True):
settings = Settings.from_env_file(env_file)
self.assertEqual(settings.pg_host, "db.example.test")
self.assertEqual(settings.pg_port, 6432)
self.assertEqual(
[(role.user, role.schema) for role in settings.pg_roles],
[
("han_app", "han_app"),
("bitrix_local_app", "bitrix_local"),
("bitrix_sync_user", "bitrix_sync"),
("message_safety_app", "message_safety"),
("keycloak_user", "keycloak"),
],
)
self.assertEqual(settings.buckets["documents"], "documents")
self.assertTrue(settings.check_cors)
self.assertFalse(settings.check_migrations)
def test_rejects_non_verifying_postgres_tls(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
ca = root / "ca.pem"
ca.write_text("test ca", encoding="utf-8")
env_file = root / ".env"
env_file.write_text(
valid_env(ca).replace(
"HAN_PG_SSLMODE=verify-full",
"HAN_PG_SSLMODE=require",
),
encoding="utf-8",
)
with patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(ConfigError, "verify-full"):
Settings.from_env_file(env_file)
def test_requires_public_url_when_cors_enabled(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
ca = root / "ca.pem"
ca.write_text("test ca", encoding="utf-8")
env_file = root / ".env"
env_file.write_text(
valid_env(ca).replace(
"PUBLIC_WEB_URL=https://chat.example.test",
"PUBLIC_WEB_URL=",
),
encoding="utf-8",
)
with patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(ConfigError, "PUBLIC_WEB_URL"):
Settings.from_env_file(env_file)
class SafetyTests(unittest.TestCase):
def test_sanitize_redacts_secret_and_url_query(self) -> None:
value = (
"password=do-not-print "
"url=https://bucket.example/object?X-Amz-Credential=secret&X-Amz-Signature=x"
)
sanitized = sanitize(value)
self.assertNotIn("do-not-print", sanitized)
self.assertNotIn("X-Amz", sanitized)
self.assertNotIn("Signature", sanitized)
self.assertIn("password=<redacted>", sanitized)
self.assertIn("https://bucket.example/object", sanitized)
def test_reporter_exit_code_and_json_are_secret_free(self) -> None:
reporter = Reporter()
with redirect_stdout(io.StringIO()):
reporter.add("ok", "PASS", "url=https://example.test/a?token=secret")
reporter.add("bad", "FAIL", "password=hidden")
with tempfile.TemporaryDirectory() as directory:
report_path = Path(directory) / "report.json"
reporter.write_json(report_path)
data = json.loads(report_path.read_text(encoding="utf-8"))
self.assertEqual(reporter.exit_code, 1)
serialized = json.dumps(data)
self.assertNotIn("hidden", serialized)
self.assertNotIn("?token", serialized)
def test_boolean_parser(self) -> None:
self.assertTrue(as_bool("yes"))
self.assertFalse(as_bool("OFF"))
with self.assertRaises(ConfigError):
as_bool("sometimes")
if __name__ == "__main__":
unittest.main()