diff --git a/ddtestpy/internal/api_client.py b/ddtestpy/internal/api_client.py index f963e43..e5f0f6b 100644 --- a/ddtestpy/internal/api_client.py +++ b/ddtestpy/internal/api_client.py @@ -10,7 +10,7 @@ from ddtestpy.internal.constants import EMPTY_NAME from ddtestpy.internal.git import GitTag -from ddtestpy.internal.http import BackendConnector +from ddtestpy.internal.http import BackendConnectorSetup from ddtestpy.internal.http import FileAttachment from ddtestpy.internal.test_data import ITRSkippingLevel from ddtestpy.internal.test_data import ModuleRef @@ -24,25 +24,19 @@ class APIClient: def __init__( self, - site: str, - api_key: str, service: str, env: str, env_tags: t.Dict[str, str], itr_skipping_level: ITRSkippingLevel, configurations: t.Dict[str, str], + connector_setup: BackendConnectorSetup, ) -> None: - self.site = site - self.api_key = api_key self.service = service self.env = env self.env_tags = env_tags self.itr_skipping_level = itr_skipping_level self.configurations = configurations - - self.base_url = f"https://api.{self.site}" - - self.connector = BackendConnector(host=f"api.{self.site}", default_headers={"dd-api-key": self.api_key}) + self.connector = connector_setup.get_connector_for_subdomain("api") def close(self) -> None: self.connector.close() diff --git a/ddtestpy/internal/constants.py b/ddtestpy/internal/constants.py index b4bf577..1725551 100644 --- a/ddtestpy/internal/constants.py +++ b/ddtestpy/internal/constants.py @@ -2,6 +2,9 @@ DEFAULT_ENV_NAME = "none" DEFAULT_SITE = "datadoghq.com" +DEFAULT_AGENT_HOSTNAME = "localhost" +DEFAULT_AGENT_PORT = 8126 + TAG_TRUE = "true" TAG_FALSE = "false" diff --git a/ddtestpy/internal/errors.py b/ddtestpy/internal/errors.py new file mode 100644 index 0000000..af00673 --- /dev/null +++ b/ddtestpy/internal/errors.py @@ -0,0 +1,2 @@ +class SetupError(Exception): + pass diff --git a/ddtestpy/internal/http.py b/ddtestpy/internal/http.py index b83b9d1..3de9cb4 100644 --- a/ddtestpy/internal/http.py +++ b/ddtestpy/internal/http.py @@ -1,57 +1,197 @@ from __future__ import annotations +from abc import abstractmethod from dataclasses import dataclass import gzip import http.client import io import json import logging +import os import threading import time import typing as t +from urllib.parse import ParseResult +from urllib.parse import urlparse import uuid +from ddtestpy.internal.constants import DEFAULT_AGENT_HOSTNAME +from ddtestpy.internal.constants import DEFAULT_AGENT_PORT +from ddtestpy.internal.constants import DEFAULT_SITE +from ddtestpy.internal.errors import SetupError +from ddtestpy.internal.utils import asbool + DEFAULT_TIMEOUT_SECONDS = 15.0 log = logging.getLogger(__name__) +class BackendConnectorSetup: + """ + Logic for detecting the backend connection mode (agentless or EVP) and creating new connectors. + """ + + @abstractmethod + def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: + """ + Return a backend connector for the given subdomain (e.g., api, citestcov-intake, citestcycle-intake). + + This method must be implemented for each backend connection mode subclass. + """ + pass + + @classmethod + def detect_setup(cls) -> BackendConnectorSetup: + """ + Detect which backend connection mode to use and return a configured instance of the corresponding subclass. + """ + if asbool(os.environ.get("DD_CIVISIBILITY_AGENTLESS_ENABLED")): + log.info("Connecting to backend in agentless mode") + return cls._detect_agentless_setup() + + else: + log.info("Connecting to backend through agent in EVP proxy mode") + return cls._detect_evp_proxy_setup() + + @classmethod + def _detect_agentless_setup(cls) -> BackendConnectorSetup: + """ + Detect settings for agentless backend connection mode. + """ + site = os.environ.get("DD_SITE") or DEFAULT_SITE + api_key = os.environ.get("DD_API_KEY") + + if not api_key: + raise SetupError("DD_API_KEY environment variable is not set") + + return BackendConnectorAgentlessSetup(site=site, api_key=api_key) + + @classmethod + def _detect_evp_proxy_setup(cls) -> BackendConnectorSetup: + """ + Detect settings for EVP proxy mode backend connection mode. + """ + agent_url = os.environ.get("DD_TRACE_AGENT_URL") + if not agent_url: + agent_host = ( + os.environ.get("DD_TRACE_AGENT_HOSTNAME") or os.environ.get("DD_AGENT_HOST") or DEFAULT_AGENT_HOSTNAME + ) + agent_port = ( + os.environ.get("DD_TRACE_AGENT_PORT") or os.environ.get("DD_AGENT_PORT") or str(DEFAULT_AGENT_PORT) + ) + agent_url = f"http://{agent_host}:{agent_port}" + + agent_url = agent_url.rstrip("/") # Avoid an extra / when concatenating with the base path + + # Get info from agent to check if the agent is there, and which EVP proxy version it supports. + try: + connector = BackendConnector(agent_url) + response, response_data = connector.get_json("/info") + endpoints = response_data.get("endpoints", []) + connector.close() + except Exception as e: + raise SetupError(f"Error connecting to Datadog agent at {agent_url}: {e}") + + if response.status != 200: + raise SetupError( + f"Error connecting to Datadog agent at {agent_url}: status {response.status}, " + f"response {response_data!r}" + ) + + if "/evp_proxy/v4/" in endpoints: + return BackendConnectorEVPProxySetup(url=f"{agent_url}/evp_proxy/v4", use_gzip=True) + + if "/evp_proxy/v2/" in endpoints: + return BackendConnectorEVPProxySetup(url=f"{agent_url}/evp_proxy/v2", use_gzip=False) + + raise SetupError(f"Datadog agent at {agent_url} does not support EVP proxy mode") + + +class BackendConnectorAgentlessSetup(BackendConnectorSetup): + def __init__(self, site: str, api_key: str) -> None: + self.site = site + self.api_key = api_key + + def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: + return BackendConnector( + url=f"https://{subdomain}.{self.site}", + default_headers={"dd-api-key": self.api_key}, + use_gzip=True, + ) + + +class BackendConnectorEVPProxySetup(BackendConnectorSetup): + def __init__(self, url: str, use_gzip: bool) -> None: + self.url = url + self.use_gzip = use_gzip + + def get_connector_for_subdomain(self, subdomain: str) -> BackendConnector: + return BackendConnector( + url=self.url, + default_headers={"X-Datadog-EVP-Subdomain": subdomain}, + use_gzip=self.use_gzip, + ) + + class BackendConnector(threading.local): def __init__( self, - host: str, - port: int = 443, + url: str, default_headers: t.Optional[t.Dict[str, str]] = None, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, - accept_gzip: bool = True, + use_gzip: bool = False, ): - self.conn = http.client.HTTPSConnection(host=host, port=port, timeout=timeout_seconds) + parsed_url = urlparse(url) + self.conn = self._make_connection(parsed_url, timeout_seconds) self.default_headers = default_headers or {} - if accept_gzip: + self.base_path = parsed_url.path.rstrip("/") + self.use_gzip = use_gzip + if self.use_gzip: self.default_headers["Accept-Encoding"] = "gzip" def close(self) -> None: self.conn.close() + def _make_connection(self, parsed_url: ParseResult, timeout_seconds: float) -> http.client.HTTPConnection: + if parsed_url.scheme == "http": + if not parsed_url.hostname: + raise SetupError(f"No hostname provided in {parsed_url.geturl()}") + + return http.client.HTTPConnection( + host=parsed_url.hostname, port=parsed_url.port or 80, timeout=timeout_seconds + ) + + if parsed_url.scheme == "https": + if not parsed_url.hostname: + raise SetupError(f"No hostname provided in {parsed_url.geturl()}") + + return http.client.HTTPSConnection( + host=parsed_url.hostname, port=parsed_url.port or 443, timeout=timeout_seconds + ) + + # TODO: Unix domain socket support. + + raise SetupError(f"Unknown scheme {parsed_url.scheme} in {parsed_url.geturl()}") + # TODO: handle retries def request( self, method: str, path: str, - data: bytes, + data: t.Optional[bytes] = None, headers: t.Optional[t.Dict[str, str]] = None, send_gzip: bool = False, ) -> t.Tuple[http.client.HTTPResponse, bytes]: full_headers = self.default_headers | (headers or {}) - if send_gzip: + if send_gzip and self.use_gzip and data is not None: data = gzip.compress(data, compresslevel=6) full_headers["Content-Encoding"] = "gzip" start_time = time.time() - self.conn.request(method, path, body=data, headers=full_headers) + self.conn.request(method, self.base_path + path, body=data, headers=full_headers) response = self.conn.getresponse() if response.headers.get("Content-Encoding") == "gzip": @@ -67,6 +207,11 @@ def request( return response, response_data + def get_json(self, path: str, headers: t.Optional[t.Dict[str, str]] = None, send_gzip: bool = False) -> t.Any: + headers = {"Content-Type": "application/json"} | (headers or {}) + response, response_data = self.request("GET", path=path, headers=headers, send_gzip=send_gzip) + return response, json.loads(response_data) + def post_json( self, path: str, data: t.Any, headers: t.Optional[t.Dict[str, str]] = None, send_gzip: bool = False ) -> t.Any: diff --git a/ddtestpy/internal/pytest/plugin.py b/ddtestpy/internal/pytest/plugin.py index 323a6cd..bb1f876 100644 --- a/ddtestpy/internal/pytest/plugin.py +++ b/ddtestpy/internal/pytest/plugin.py @@ -17,6 +17,7 @@ from ddtestpy.internal.coverage_api import install_coverage from ddtestpy.internal.ddtrace import install_global_trace_filter from ddtestpy.internal.ddtrace import trace_context +from ddtestpy.internal.errors import SetupError from ddtestpy.internal.git import get_workspace_path from ddtestpy.internal.logging import catch_and_log_exceptions from ddtestpy.internal.logging import setup_logging @@ -628,7 +629,13 @@ def pytest_load_initial_conftests( test_framework="pytest", test_framework_version=pytest.__version__, ) - session_manager = SessionManager(session=session) + + try: + session_manager = SessionManager(session=session) + except SetupError as e: + log.error("%s", e) + yield + return early_config.stash[SESSION_MANAGER_STASH_KEY] = session_manager diff --git a/ddtestpy/internal/session_manager.py b/ddtestpy/internal/session_manager.py index b7d1cf3..469826a 100644 --- a/ddtestpy/internal/session_manager.py +++ b/ddtestpy/internal/session_manager.py @@ -11,10 +11,10 @@ from ddtestpy.internal.codeowners import Codeowners from ddtestpy.internal.constants import DEFAULT_ENV_NAME from ddtestpy.internal.constants import DEFAULT_SERVICE_NAME -from ddtestpy.internal.constants import DEFAULT_SITE from ddtestpy.internal.env_tags import get_env_tags from ddtestpy.internal.git import Git from ddtestpy.internal.git import GitTag +from ddtestpy.internal.http import BackendConnectorSetup from ddtestpy.internal.platform import get_platform_tags from ddtestpy.internal.retry_handlers import AttemptToFixHandler from ddtestpy.internal.retry_handlers import AutoTestRetriesHandler @@ -61,20 +61,16 @@ def __init__(self, session: TestSession) -> None: self.service = _get_service_name_from_git_repo(self.env_tags) or DEFAULT_SERVICE_NAME self.env = os.environ.get("DD_ENV") or DEFAULT_ENV_NAME - self.site = os.environ.get("DD_SITE") or DEFAULT_SITE - self.api_key = os.environ.get("DD_API_KEY") - if not self.api_key: - raise RuntimeError("DD_API_KEY environment variable is not set") + self.connector_setup = BackendConnectorSetup.detect_setup() self.api_client = APIClient( - site=self.site, - api_key=self.api_key, service=self.service, env=self.env, env_tags=self.env_tags, itr_skipping_level=self.itr_skipping_level, configurations=self.platform_tags, + connector_setup=self.connector_setup, ) self.settings = self.api_client.get_settings() self.known_tests = self.api_client.get_known_tests() if self.settings.known_tests_enabled else set() @@ -93,8 +89,8 @@ def __init__(self, session: TestSession) -> None: # Retry handlers must be set up after collection phase for EFD faulty session logic to work. self.retry_handlers: t.List[RetryHandler] = [] - self.writer = TestOptWriter(site=self.site, api_key=self.api_key) - self.coverage_writer = TestCoverageWriter(site=self.site, api_key=self.api_key) + self.writer = TestOptWriter(connector_setup=self.connector_setup) + self.coverage_writer = TestCoverageWriter(connector_setup=self.connector_setup) self.session = session self.session.set_service(self.service) diff --git a/ddtestpy/internal/writer.py b/ddtestpy/internal/writer.py index 5864f5b..d960f93 100644 --- a/ddtestpy/internal/writer.py +++ b/ddtestpy/internal/writer.py @@ -8,7 +8,7 @@ import msgpack # type: ignore import ddtestpy -from ddtestpy.internal.http import BackendConnector +from ddtestpy.internal.http import BackendConnectorSetup from ddtestpy.internal.http import FileAttachment from ddtestpy.internal.test_data import TestItem from ddtestpy.internal.test_data import TestModule @@ -31,10 +31,7 @@ class Event(dict[str, t.Any]): class BaseWriter(ABC): - def __init__(self, site: str, api_key: str) -> None: - self.site = site - self.api_key = api_key - + def __init__(self) -> None: self.lock = threading.RLock() self.should_finish = threading.Event() self.flush_interval_seconds = 60 @@ -86,8 +83,8 @@ def _send_events(self, events: t.List[Event]) -> None: class TestOptWriter(BaseWriter): __test__ = False - def __init__(self, site: str, api_key: str) -> None: - super().__init__(site=site, api_key=api_key) + def __init__(self, connector_setup: BackendConnectorSetup) -> None: + super().__init__() self.metadata: t.Dict[str, t.Dict[str, str]] = { "*": { @@ -108,10 +105,7 @@ def __init__(self, site: str, api_key: str) -> None: }, } - self.connector = BackendConnector( - host=f"citestcycle-intake.{self.site}", - default_headers={"dd-api-key": self.api_key}, - ) + self.connector = connector_setup.get_connector_for_subdomain("citestcycle-intake") self.serializers: t.Dict[t.Type[TestItem[t.Any, t.Any]], EventSerializer[t.Any]] = { TestRun: serialize_test_run, @@ -142,13 +136,10 @@ def _send_events(self, events: t.List[Event]) -> None: class TestCoverageWriter(BaseWriter): __test__ = False - def __init__(self, site: str, api_key: str) -> None: - super().__init__(site=site, api_key=api_key) + def __init__(self, connector_setup: BackendConnectorSetup) -> None: + super().__init__() - self.connector = BackendConnector( - host=f"citestcov-intake.{self.site}", - default_headers={"dd-api-key": self.api_key}, - ) + self.connector = connector_setup.get_connector_for_subdomain("citestcov-intake") def put_coverage(self, test_run: TestRun, coverage_bitmaps: t.Iterable[t.Tuple[str, bytes]]) -> None: files = [{"filename": pathname, "bitmap": bitmap} for pathname, bitmap in coverage_bitmaps] diff --git a/pyproject.toml b/pyproject.toml index b270cd4..37d7788 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ python = ["3.9", "3.10", "3.11", "3.12", "3.13"] [tool.hatch.envs.int_test.env-vars] DD_API_KEY = "foobar" +DD_CIVISIBILITY_AGENTLESS_ENABLED = "true" [tool.hatch.envs.int_test.scripts] test = "pytest {args:test_fixtures}" diff --git a/tests/internal/test_http.py b/tests/internal/test_http.py index ffde698..92e40f4 100644 --- a/tests/internal/test_http.py +++ b/tests/internal/test_http.py @@ -1,11 +1,20 @@ """Tests for ddtestpy.internal.http module.""" +import http.client +import os from unittest.mock import Mock from unittest.mock import patch +import pytest + +from ddtestpy.internal.errors import SetupError from ddtestpy.internal.http import DEFAULT_TIMEOUT_SECONDS from ddtestpy.internal.http import BackendConnector +from ddtestpy.internal.http import BackendConnectorAgentlessSetup +from ddtestpy.internal.http import BackendConnectorEVPProxySetup +from ddtestpy.internal.http import BackendConnectorSetup from ddtestpy.internal.http import FileAttachment +from tests.mocks import mock_backend_connector class TestBackendConnector: @@ -18,10 +27,20 @@ def test_constants(self) -> None: @patch("http.client.HTTPSConnection") def test_init_default_parameters(self, mock_https_connection: Mock) -> None: """Test BackendConnector initialization with default parameters.""" - connector = BackendConnector(host="api.example.com") + connector = BackendConnector(url="https://api.example.com", use_gzip=True) mock_https_connection.assert_called_once_with(host="api.example.com", port=443, timeout=DEFAULT_TIMEOUT_SECONDS) assert connector.default_headers == {"Accept-Encoding": "gzip"} + assert connector.base_path == "" + + @patch("http.client.HTTPSConnection") + def test_init_custom_parameters(self, mock_https_connection: Mock) -> None: + """Test BackendConnector initialization with default parameters.""" + connector = BackendConnector(url="https://api.example.com/some-path", use_gzip=False) + + mock_https_connection.assert_called_once_with(host="api.example.com", port=443, timeout=DEFAULT_TIMEOUT_SECONDS) + assert connector.default_headers == {} + assert connector.base_path == "/some-path" @patch("http.client.HTTPSConnection") @patch("uuid.uuid4") @@ -41,7 +60,7 @@ def test_post_files_multiple_files(self, mock_uuid: Mock, mock_https_connection: mock_https_connection.return_value = mock_conn # Test post_files with multiple files - connector = BackendConnector(host="api.example.com") + connector = BackendConnector(url="https://api.example.com") files = [ FileAttachment("file1", "doc1.txt", "text/plain", b"content1"), FileAttachment("file2", "doc2.json", "application/json", b"content2"), @@ -59,3 +78,155 @@ def test_post_files_multiple_files(self, mock_uuid: Mock, mock_https_connection: assert b"content1" in body assert b"content2" in body assert body.count(b"--boundary123") == 3 # 2 file separators + 1 end + + +class TestBackendConnectorSetup: + def test_detect_agentless_setup_ok(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {"DD_CIVISIBILITY_AGENTLESS_ENABLED": "true", "DD_API_KEY": "the-key"}) + + connector_setup = BackendConnectorSetup.detect_setup() + assert isinstance(connector_setup, BackendConnectorAgentlessSetup) + + connector = connector_setup.get_connector_for_subdomain("api") + assert isinstance(connector.conn, http.client.HTTPSConnection) + assert connector.conn.host == "api.datadoghq.com" + assert connector.conn.port == 443 + assert connector.use_gzip is True + assert connector.default_headers["dd-api-key"] == "the-key" + + def test_detect_agentless_setup_ok_with_site(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + os, + "environ", + {"DD_CIVISIBILITY_AGENTLESS_ENABLED": "true", "DD_API_KEY": "the-key", "DD_SITE": "datadoghq.eu"}, + ) + + connector_setup = BackendConnectorSetup.detect_setup() + assert isinstance(connector_setup, BackendConnectorAgentlessSetup) + + connector = connector_setup.get_connector_for_subdomain("api") + assert isinstance(connector.conn, http.client.HTTPSConnection) + assert connector.conn.host == "api.datadoghq.eu" + assert connector.conn.port == 443 + assert connector.use_gzip is True + assert connector.default_headers["dd-api-key"] == "the-key" + + def test_detect_agentless_setup_no_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + os, + "environ", + {"DD_CIVISIBILITY_AGENTLESS_ENABLED": "true"}, + ) + + with pytest.raises(SetupError, match="DD_API_KEY environment variable is not set"): + BackendConnectorSetup.detect_setup() + + def test_detect_evp_proxy_mode_v4(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {}) + + backend_connector_mock = ( + mock_backend_connector().with_get_json_response("/info", {"endpoints": ["/evp_proxy/v4/"]}).build() + ) + with patch("ddtestpy.internal.http.BackendConnector", return_value=backend_connector_mock): + connector_setup = BackendConnectorSetup.detect_setup() + + assert isinstance(connector_setup, BackendConnectorEVPProxySetup) + + connector = connector_setup.get_connector_for_subdomain("api") + assert isinstance(connector.conn, http.client.HTTPConnection) + assert connector.conn.host == "localhost" + assert connector.conn.port == 8126 + assert connector.base_path == "/evp_proxy/v4" + assert connector.use_gzip is True + assert connector.default_headers["X-Datadog-EVP-Subdomain"] == "api" + + def test_detect_evp_proxy_mode_v2(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {}) + + backend_connector_mock = ( + mock_backend_connector().with_get_json_response("/info", {"endpoints": ["/evp_proxy/v2/"]}).build() + ) + with patch("ddtestpy.internal.http.BackendConnector", return_value=backend_connector_mock): + connector_setup = BackendConnectorSetup.detect_setup() + + assert isinstance(connector_setup, BackendConnectorEVPProxySetup) + + connector = connector_setup.get_connector_for_subdomain("api") + assert isinstance(connector.conn, http.client.HTTPConnection) + assert connector.conn.host == "localhost" + assert connector.conn.port == 8126 + assert connector.base_path == "/evp_proxy/v2" + assert connector.use_gzip is False + assert connector.default_headers["X-Datadog-EVP-Subdomain"] == "api" + + def test_detect_evp_proxy_mode_no_evp_support(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {}) + + backend_connector_mock = mock_backend_connector().with_get_json_response("/info", {"endpoints": []}).build() + with patch("ddtestpy.internal.http.BackendConnector", return_value=backend_connector_mock): + with pytest.raises(SetupError, match="Datadog agent .* does not support EVP proxy mode"): + BackendConnectorSetup.detect_setup() + + def test_detect_evp_proxy_mode_no_agent(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {}) + + with patch("ddtestpy.internal.http.BackendConnector.get_json", side_effect=ConnectionRefusedError("no bueno")): + with pytest.raises(SetupError, match="Error connecting to Datadog agent.*no bueno"): + BackendConnectorSetup.detect_setup() + + def test_detect_evp_proxy_mode_v4_custom_dd_trace_agent_url(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {"DD_TRACE_AGENT_URL": "http://somehost:1234"}) + + backend_connector_mock = ( + mock_backend_connector().with_get_json_response("/info", {"endpoints": ["/evp_proxy/v4/"]}).build() + ) + with patch("ddtestpy.internal.http.BackendConnector", return_value=backend_connector_mock): + connector_setup = BackendConnectorSetup.detect_setup() + + assert isinstance(connector_setup, BackendConnectorEVPProxySetup) + + connector = connector_setup.get_connector_for_subdomain("api") + assert isinstance(connector.conn, http.client.HTTPConnection) + assert connector.conn.host == "somehost" + assert connector.conn.port == 1234 + assert connector.base_path == "/evp_proxy/v4" + assert connector.use_gzip is True + assert connector.default_headers["X-Datadog-EVP-Subdomain"] == "api" + + def test_detect_evp_proxy_mode_v4_custom_dd_trace_agent_hostname(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {"DD_TRACE_AGENT_HOSTNAME": "somehost", "DD_TRACE_AGENT_PORT": "5678"}) + + backend_connector_mock = ( + mock_backend_connector().with_get_json_response("/info", {"endpoints": ["/evp_proxy/v4/"]}).build() + ) + with patch("ddtestpy.internal.http.BackendConnector", return_value=backend_connector_mock): + connector_setup = BackendConnectorSetup.detect_setup() + + assert isinstance(connector_setup, BackendConnectorEVPProxySetup) + + connector = connector_setup.get_connector_for_subdomain("api") + assert isinstance(connector.conn, http.client.HTTPConnection) + assert connector.conn.host == "somehost" + assert connector.conn.port == 5678 + assert connector.base_path == "/evp_proxy/v4" + assert connector.use_gzip is True + assert connector.default_headers["X-Datadog-EVP-Subdomain"] == "api" + + def test_detect_evp_proxy_mode_v4_custom_dd_agent_host(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(os, "environ", {"DD_AGENT_HOST": "somehost", "DD_AGENT_PORT": "5678"}) + + backend_connector_mock = ( + mock_backend_connector().with_get_json_response("/info", {"endpoints": ["/evp_proxy/v4/"]}).build() + ) + with patch("ddtestpy.internal.http.BackendConnector", return_value=backend_connector_mock): + connector_setup = BackendConnectorSetup.detect_setup() + + assert isinstance(connector_setup, BackendConnectorEVPProxySetup) + + connector = connector_setup.get_connector_for_subdomain("api") + assert isinstance(connector.conn, http.client.HTTPConnection) + assert connector.conn.host == "somehost" + assert connector.conn.port == 5678 + assert connector.base_path == "/evp_proxy/v4" + assert connector.use_gzip is True + assert connector.default_headers["X-Datadog-EVP-Subdomain"] == "api" diff --git a/tests/internal/test_session_manager.py b/tests/internal/test_session_manager.py index 3fa3de2..deb9831 100644 --- a/tests/internal/test_session_manager.py +++ b/tests/internal/test_session_manager.py @@ -175,7 +175,7 @@ def test_multiple_tests_same_skippable_suite(self) -> None: class TestSessionNameTest: def test_session_name_explicitly_from_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: - env = {"DD_TEST_SESSION_NAME": "the_name", "DD_API_KEY": "somekey"} + env = {"DD_TEST_SESSION_NAME": "the_name", "DD_API_KEY": "somekey", "DD_CIVISIBILITY_AGENTLESS_ENABLED": "true"} monkeypatch.setattr(os, "environ", env) session_manager = session_manager_mock().with_env_tags({CITag.JOB_NAME: "the_job"}).build_real_with_mocks(env) @@ -184,7 +184,7 @@ def test_session_name_explicitly_from_env_var(self, monkeypatch: pytest.MonkeyPa assert session_manager.writer.metadata["*"]["test_session.name"] == expected_name def test_session_name_from_job_name(self, monkeypatch: pytest.MonkeyPatch) -> None: - env = {"DD_API_KEY": "somekey"} + env = {"DD_API_KEY": "somekey", "DD_CIVISIBILITY_AGENTLESS_ENABLED": "true"} monkeypatch.setattr(os, "environ", env) session_manager = session_manager_mock().with_env_tags({CITag.JOB_NAME: "the_job"}).build_real_with_mocks(env) @@ -193,7 +193,7 @@ def test_session_name_from_job_name(self, monkeypatch: pytest.MonkeyPatch) -> No assert session_manager.writer.metadata["*"]["test_session.name"] == expected_name def test_session_name_from_test_command(self, monkeypatch: pytest.MonkeyPatch) -> None: - env = {"DD_API_KEY": "somekey"} + env = {"DD_API_KEY": "somekey", "DD_CIVISIBILITY_AGENTLESS_ENABLED": "true"} monkeypatch.setattr(os, "environ", env) session_manager = session_manager_mock().with_env_tags({}).build_real_with_mocks(env) diff --git a/tests/internal/test_writer.py b/tests/internal/test_writer.py index 8f89437..35c40ad 100644 --- a/tests/internal/test_writer.py +++ b/tests/internal/test_writer.py @@ -3,6 +3,7 @@ from unittest.mock import Mock from unittest.mock import patch +from ddtestpy.internal.http import BackendConnectorAgentlessSetup from ddtestpy.internal.test_data import TestModule from ddtestpy.internal.test_data import TestRun from ddtestpy.internal.test_data import TestSession @@ -42,16 +43,14 @@ def test_event_dict_operations(self) -> None: class TestTestOptWriter: """Tests for TestOptWriter class.""" - @patch("ddtestpy.internal.writer.BackendConnector") + @patch("ddtestpy.internal.http.BackendConnector") def test_testopt_writer_initialization(self, mock_backend_connector: Mock) -> None: """Test TestOptWriter initialization.""" mock_connector = Mock() mock_backend_connector.return_value = mock_connector - writer = TestOptWriter(site="datadoghq.com", api_key="test_key") + writer = TestOptWriter(BackendConnectorAgentlessSetup(site="datadoghq.com", api_key="test_key")) - assert writer.site == "datadoghq.com" - assert writer.api_key == "test_key" assert writer.connector == mock_connector # Check metadata structure @@ -68,7 +67,7 @@ def test_testopt_writer_initialization(self, mock_backend_connector: Mock) -> No assert TestModule in writer.serializers assert TestSession in writer.serializers - @patch("ddtestpy.internal.writer.BackendConnector") + @patch("ddtestpy.internal.http.BackendConnector") @patch("msgpack.packb") def test_send_events(self, mock_packb: Mock, mock_backend_connector: Mock) -> None: """Test sending events to backend.""" @@ -78,7 +77,7 @@ def test_send_events(self, mock_packb: Mock, mock_backend_connector: Mock) -> No mock_connector.request.return_value = (Mock(), {}) mock_packb.return_value = b"packed_data" - writer = TestOptWriter(site="test", api_key="key") + writer = TestOptWriter(BackendConnectorAgentlessSetup(site="test", api_key="key")) events = [Event(type="test1"), Event(type="test2")] writer._send_events(events) @@ -104,27 +103,25 @@ def test_send_events(self, mock_packb: Mock, mock_backend_connector: Mock) -> No class TestTestCoverageWriter: """Tests for TestCoverageWriter class.""" - @patch("ddtestpy.internal.writer.BackendConnector") + @patch("ddtestpy.internal.http.BackendConnector") def test_coverage_writer_initialization(self, mock_backend_connector: Mock) -> None: """Test TestCoverageWriter initialization.""" mock_connector = Mock() mock_backend_connector.return_value = mock_connector - writer = TestCoverageWriter(site="datadoghq.com", api_key="test_key") + writer = TestCoverageWriter(BackendConnectorAgentlessSetup(site="datadoghq.com", api_key="test_key")) - assert writer.site == "datadoghq.com" - assert writer.api_key == "test_key" assert writer.connector == mock_connector # Check connector initialization mock_backend_connector.assert_called_once_with( - host="citestcov-intake.datadoghq.com", default_headers={"dd-api-key": "test_key"} + url="https://citestcov-intake.datadoghq.com", default_headers={"dd-api-key": "test_key"}, use_gzip=True ) - @patch("ddtestpy.internal.writer.BackendConnector") + @patch("ddtestpy.internal.http.BackendConnector") def test_put_coverage(self, mock_backend_connector: Mock) -> None: """Test putting coverage data.""" - writer = TestCoverageWriter(site="test", api_key="key") + writer = TestCoverageWriter(BackendConnectorAgentlessSetup(site="test", api_key="key")) # Mock test run test_run = Mock() @@ -147,7 +144,7 @@ def test_put_coverage(self, mock_backend_connector: Mock) -> None: assert event["span_id"] == 789 assert len(event["files"]) == 2 - @patch("ddtestpy.internal.writer.BackendConnector") + @patch("ddtestpy.internal.http.BackendConnector") @patch("msgpack.packb") def test_send_coverage_events(self, mock_packb: Mock, mock_backend_connector: Mock) -> None: """Test sending coverage events.""" @@ -157,7 +154,7 @@ def test_send_coverage_events(self, mock_packb: Mock, mock_backend_connector: Mo mock_connector.post_files.return_value = (Mock(), {}) mock_packb.return_value = b"packed_coverage_data" - writer = TestCoverageWriter(site="test", api_key="key") + writer = TestCoverageWriter(BackendConnectorAgentlessSetup(site="test", api_key="key")) events = [Event(type="coverage1"), Event(type="coverage2")] writer._send_events(events) diff --git a/tests/mocks.py b/tests/mocks.py index 00edd6b..7d53102 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -22,6 +22,7 @@ from ddtestpy.internal.api_client import Settings from ddtestpy.internal.api_client import TestManagementSettings from ddtestpy.internal.api_client import TestProperties +from ddtestpy.internal.http import BackendConnectorSetup from ddtestpy.internal.session_manager import SessionManager from ddtestpy.internal.test_data import ModuleRef from ddtestpy.internal.test_data import SuiteRef @@ -72,7 +73,12 @@ def settings( @staticmethod def test_environment() -> t.Dict[str, str]: """Create default test environment variables.""" - return {"DD_API_KEY": "test-api-key", "DD_SERVICE": "test-service", "DD_ENV": "test-env"} + return { + "DD_API_KEY": "test-api-key", + "DD_CIVISIBILITY_AGENTLESS_ENABLED": "true", + "DD_SERVICE": "test-service", + "DD_ENV": "test-env", + } @staticmethod def test_session(name: str = "test") -> TestSession: @@ -417,6 +423,7 @@ class BackendConnectorMockBuilder: def __init__(self) -> None: self._post_json_responses: t.Dict[str, t.Any] = {} + self._get_json_responses: t.Dict[str, t.Any] = {} self._request_responses: t.Dict[str, t.Any] = {} self._post_files_responses: t.Dict[str, t.Any] = {} @@ -425,6 +432,11 @@ def with_post_json_response(self, endpoint: str, response_data: t.Any) -> "Backe self._post_json_responses[endpoint] = response_data return self + def with_get_json_response(self, endpoint: str, response_data: t.Any) -> "BackendConnectorMockBuilder": + """Mock a specific POST JSON endpoint response.""" + self._get_json_responses[endpoint] = response_data + return self + def with_request_response(self, method: str, path: str, response_data: t.Any) -> "BackendConnectorMockBuilder": """Mock a specific HTTP request response.""" self._request_responses[f"{method}:{path}"] = response_data @@ -432,26 +444,30 @@ def with_request_response(self, method: str, path: str, response_data: t.Any) -> def build(self) -> Mock: """Build the BackendConnector mock.""" - from ddtestpy.internal.http import BackendConnector - - mock_connector = Mock(spec=BackendConnector) + mock_connector = Mock() # Mock methods to prevent real HTTP calls def mock_post_json(endpoint: str, data: t.Any) -> t.Tuple[Mock, t.Any]: if endpoint in self._post_json_responses: - return Mock(), self._post_json_responses[endpoint] - return Mock(), {} + return Mock(status=200), self._post_json_responses[endpoint] + return Mock(status=404), {} + + def mock_get_json(endpoint: str) -> t.Tuple[Mock, t.Any]: + if endpoint in self._get_json_responses: + return Mock(status=200), self._get_json_responses[endpoint] + return Mock(status=404), {} def mock_request(method: str, path: str, **kwargs: t.Any) -> t.Tuple[Mock, t.Any]: key = f"{method}:{path}" if key in self._request_responses: - return Mock(), self._request_responses[key] - return Mock(), {} + return Mock(status=200), self._request_responses[key] + return Mock(status=404), {} def mock_post_files(path: str, files: t.Any, **kwargs: t.Any) -> t.Tuple[Mock, t.Dict[str, t.Any]]: - return Mock(), {} + return Mock(status=200), {} mock_connector.post_json.side_effect = mock_post_json + mock_connector.get_json.side_effect = mock_get_json mock_connector.request.side_effect = mock_request mock_connector.post_files.side_effect = mock_post_files @@ -537,14 +553,21 @@ def mock_backend_connector() -> "BackendConnectorMockBuilder": return BackendConnectorMockBuilder() -def setup_standard_mocks() -> t.ContextManager[t.Any]: +class BackendConnectorMockSetup: + def get_connector_for_subdomain(self, subdomain: str) -> Mock: + return mock_backend_connector().build() + + +@contextlib.contextmanager +def setup_standard_mocks() -> t.Generator[None, None, None]: """Mock calls used by the session manager to get git and platform tags.""" - return patch.multiple( + with patch.multiple( "ddtestpy.internal.session_manager", get_env_tags=Mock(return_value={}), get_platform_tags=Mock(return_value={}), Git=Mock(return_value=get_mock_git_instance()), - ) + ), patch.object(BackendConnectorSetup, "detect_setup", return_value=BackendConnectorMockSetup()): + yield def network_mocks() -> t.ContextManager[t.Any]: @@ -564,6 +587,10 @@ def _create_stack() -> t.ContextManager[t.Any]: ) ) + stack.enter_context( + patch.object(BackendConnectorSetup, "detect_setup", return_value=BackendConnectorMockSetup()) + ) + # Mock the HTTP connector to prevent any real HTTP calls mock_connector = mock_backend_connector().build() stack.enter_context(patch("ddtestpy.internal.http.BackendConnector", return_value=mock_connector)) diff --git a/tests/test_integration.py b/tests/test_integration.py index 96a6e4f..adb02dd 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -428,6 +428,7 @@ def test_retry_handler_configuration(self) -> None: os.environ, # Mock environment variables { "DD_API_KEY": "test-key", + "DD_CIVISIBILITY_AGENTLESS_ENABLED": "true", "DD_CIVISIBILITY_FLAKY_RETRY_ENABLED": "true", "DD_CIVISIBILITY_FLAKY_RETRY_COUNT": "3", "DD_CIVISIBILITY_TOTAL_FLAKY_RETRY_COUNT": "10", @@ -468,6 +469,7 @@ def test_retry_handler_logic(self) -> None: os.environ, { "DD_API_KEY": "foobar", + "DD_CIVISIBILITY_AGENTLESS_ENABLED": "true", "DD_CIVISIBILITY_FLAKY_RETRY_COUNT": "2", "DD_CIVISIBILITY_TOTAL_FLAKY_RETRY_COUNT": "5", },