Files
han-app/archive/infratest/infratest.py
T

858 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 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:
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},
s3={"addressing_style": "virtual"},
),
}
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:
# Selectel обрабатывает CORS только на vHosted URL; path-style presigned URL
# возвращает 405 даже при корректной конфигурации бакета.
url = virtual_hosted_bucket_url(
self.settings.s3_endpoint,
self.settings.buckets["quarantine"],
key,
)
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")
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:
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())