Загрузка данных


арх константы

class VaultConstants:
    """Константы для интеграции с Vault"""

    VAULT_ADDR: str = "VAULT_ADDR"
    ROLE_ID: str = "LDS_AUTO_VAULT_ROLE"
    SECRET_ID: str = "LDS_AUTO_VAULT_SECRET"
    VAULT_SKIP_VERIFY: str = "true"

    KV_MOUNT: str = "dotnet"
    SECRET_PATH_TEMPLATE: str = "config/{environment}/481/{stand_name}/LayerBuilderReportInfoHandler"
    ENVIRONMENT_TESTING: str = "Testing"
    ENVIRONMENT_DEVELOPMENT: str = "Development"
    PROCESS_EMPTY_VALUES_REJECTION_KEY: str = "ProcessEmptyValuesRejection"
    VAULT_CMD_TIMEOUT_S: int = 30






















infra\vault_config_manager.py
import json
import logging
import os
import subprocess
from typing import Optional

from constants.architecture_constants import EnvKeyConstants, VaultConstants

logger = logging.getLogger(__name__)

_session_vault_token: Optional[str] = None


class VaultConfigManager:
    """
    Управляет параметром ProcessEmptyValuesRejection в Vault через локальный CLI.

    AppRole-авторизация и kv get/put выполняются на раннере
    """

    def __init__(self) -> None:
        self._vault_token: Optional[str] = _session_vault_token

    @staticmethod
    def resolve_environment(stand_name: str) -> str:
        """
        Определяет контур Vault (Testing/Development) по подстроке в имени стенда.
        """
        stand_lower = stand_name.lower()
        if "test" in stand_lower:
            return VaultConstants.ENVIRONMENT_TESTING
        if "dev" in stand_lower:
            return VaultConstants.ENVIRONMENT_DEVELOPMENT
        raise ValueError(
            f"Неизвестный STAND_NAME='{stand_name}', невозможно определить path для настроек Vault"
        )

    def _get_stand_name(self) -> str:
        """
        Возвращает имя стенда из обязательной переменной окружения STAND_NAME.
        """
        stand_name = os.environ.get(EnvKeyConstants.STAND_NAME)
        if not stand_name:
            raise ValueError(f"Переменная окружения {EnvKeyConstants.STAND_NAME} не задана")
        return stand_name

    def resolve_secret_path(self) -> str:
        """
        Возвращает относительный путь секрета KV без mount.
        """
        stand_name = self._get_stand_name()
        environment = self.resolve_environment(stand_name)
        return VaultConstants.SECRET_PATH_TEMPLATE.format(environment=environment, stand_name=stand_name)

    def is_configured(self) -> bool:
        """
        Проверяет наличие кредов и возможность построить путь секрета.
        """
        required_env_keys = (
            VaultConstants.VAULT_ADDR,
            VaultConstants.ROLE_ID,
            VaultConstants.SECRET_ID,
            EnvKeyConstants.STAND_NAME,
        )
        if not all(os.environ.get(env_key) for env_key in required_env_keys):
            return False
        try:
            self.resolve_secret_path()
        except ValueError:
            return False
        return True

    def _build_subprocess_env(self) -> dict[str, str]:
        """
        Формирует окружение subprocess для vault CLI: VAULT_SKIP_VERIFY и кэшированный токен.
        """
        env = os.environ.copy()
        if not env.get(VaultConstants.VAULT_SKIP_VERIFY):
            env[VaultConstants.VAULT_SKIP_VERIFY] = "true"
        if self._vault_token:
            env["VAULT_TOKEN"] = self._vault_token
        return env

    def _login_approle(self) -> None:
        """
        Авторизуется через AppRole и кэширует токен на время pytest-сессии.
        """
        global _session_vault_token

        if _session_vault_token:
            self._vault_token = _session_vault_token
            return

        role_id = os.environ[VaultConstants.ROLE_ID]
        secret_id = os.environ[VaultConstants.SECRET_ID]
        output = self._run_vault_cmd(
            [
                "write",
                "-format=json",
                "auth/approle/login",
                f"role_id={role_id}",
                f"secret_id={secret_id}",
            ],
            require_token=False,
        )
        response = json.loads(output)
        token = response.get("auth", {}).get("client_token")
        if not token:
            raise RuntimeError("[VAULT] [ERROR] AppRole login не вернул client_token")

        _session_vault_token = token
        self._vault_token = token

    def _run_vault_cmd(self, args: list[str], require_token: bool = True) -> str:
        """
        Выполняет локальную команду волту и возвращает stdout.
        """
        if require_token:
            self._login_approle()

        command = ["vault", *args]
        logger.info("[VAULT] Выполняю команду: vault %s", " ".join(args[:4]))

        try:
            completed = subprocess.run(
                command,
                capture_output=True,
                text=True,
                check=True,
                timeout=VaultConstants.VAULT_CMD_TIMEOUT_S,
                env=self._build_subprocess_env(),
            )
        except subprocess.TimeoutExpired as error:
            raise RuntimeError("[VAULT] [ERROR] Команда vault превысила таймаут") from error
        except subprocess.CalledProcessError as error:
            stderr = (error.stderr or error.stdout or "").strip()
            raise RuntimeError(f"[VAULT] [ERROR] Команда vault завершилась с ошибкой: {stderr}") from error

        return completed.stdout

    def get_secret_data(self) -> dict[str, str]:
        """
        Читает текущие данные секрета из Vault KV v2.
        """
        secret_path = self.resolve_secret_path()
        output = self._run_vault_cmd(
            ["kv", "get", f"-mount={VaultConstants.KV_MOUNT}", "-format=json", secret_path]
        )
        response = json.loads(output)
        secret_data = response.get("data", {}).get("data")
        if secret_data is None:
            raise RuntimeError("[VAULT] [ERROR] Не удалось извлечь data из ответа kv get")

        return {str(key): str(value) for key, value in secret_data.items()}

    def set_process_empty_values_rejection(self, enabled: bool) -> None:
        """
        Устанавливает ProcessEmptyValuesRejection, сохраняя остальные ключи секрета.
        """
        secret_path = self.resolve_secret_path()
        merged_data = self.get_secret_data()
        merged_data[VaultConstants.PROCESS_EMPTY_VALUES_REJECTION_KEY] = "true" if enabled else "false"

        put_args = ["kv", "put", f"-mount={VaultConstants.KV_MOUNT}", secret_path]
        for key, value in merged_data.items():
            put_args.append(f"{key}={value}")

        self._run_vault_cmd(put_args)
        logger.info(
            "[VAULT] %s=%s для %s",
            VaultConstants.PROCESS_EMPTY_VALUES_REJECTION_KEY,
            merged_data[VaultConstants.PROCESS_EMPTY_VALUES_REJECTION_KEY],
            secret_path,
        )

    def ensure_disabled(self) -> None:
        """Сбрасывает ProcessEmptyValuesRejection в false."""
        self.set_process_empty_values_rejection(False)












utils/helpers/vault_pytest_utils.py
"""
Интеграция Vault с setup/teardown.

Настраивает ProcessEmptyValuesRejection перед setup стенда и сбрасывает после
завершения набора отбраковки
"""

from __future__ import annotations

import logging
import os
from typing import Any

from constants.architecture_constants import EnvKeyConstants, VaultConstants
from infra.vault_config_manager import VaultConfigManager

logger = logging.getLogger(__name__)

VAULT_CREDENTIALS_MISSING_MESSAGE = (
    "[SETUP] [ERROR] Vault не настроен для набора отбраковки "
    "(требуются VAULT_ADDR, ROLE_ID, SECRET_ID, STAND_NAME)"
)


def suite_requires_process_empty_values_rejection(suite_config: Any) -> bool:
    """
    Проверяет, нужен ли для набора ProcessEmptyValuesRejection=true в Vault.
    """
    return bool(getattr(suite_config, "requires_process_empty_values_rejection", False))


def has_vault_credentials() -> bool:
    """
    Проверяет наличие обязательных переменных окружения для работы с Vault.
    """
    return all(
        os.environ.get(env_key)
        for env_key in (
            VaultConstants.VAULT_ADDR,
            VaultConstants.ROLE_ID,
            VaultConstants.SECRET_ID,
            EnvKeyConstants.STAND_NAME,
        )
    )


def configure_vault_for_suite(suite_config: Any) -> tuple[bool, str | None]:
    """
    Настраивает ProcessEmptyValuesRejection в Vault перед подготовкой стенда.
    """
    requires_rejection = suite_requires_process_empty_values_rejection(suite_config)

    if not has_vault_credentials():
        if requires_rejection:
            return False, VAULT_CREDENTIALS_MISSING_MESSAGE
        logger.warning("[SETUP] [SKIP] Vault credentials не заданы - ProcessEmptyValuesRejection не изменен")
        return False, None

    enabled = requires_rejection
    VaultConfigManager().set_process_empty_values_rejection(enabled)
    logger.info(
        "[VAULT] ProcessEmptyValuesRejection установлен в %s для набора",
        "true" if enabled else "false",
    )
    return enabled, None


def reset_vault_process_empty_values_rejection() -> None:
    """
    Сбрасывает ProcessEmptyValuesRejection в false.
    """
    try:
        manager = VaultConfigManager()
        if manager.is_configured():
            manager.ensure_disabled()
            logger.info("[VAULT] ProcessEmptyValuesRejection сброшен в false")
    except Exception as error:
        logger.warning(
            "[TEARDOWN] [ALERT] Не удалось сбросить ProcessEmptyValuesRejection в Vault: %s",
            error,
        )