#!/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)}=", 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 "" 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())