Skip to content

Commit f37896d

Browse files
fix(ci-visibility): make ITR enable properly in EVP mode [backport #6678 to 1.18] (#6716)
Backport 54f2493 from #6684 to 1.18. Fixes an issue where ITR would not enable properly in EVP mode, due to a missing `X-Datadog-NeedsAppKey` header. This would cause requests to the endpoints that check for feature enablement to fail with "403 Forbidden" errors, disabling ITR entirely for the rest of the run. This PR also updates a couple of places to use constants rather than string literals for `dd-api-key` and `dd-app-key` headers in agentless mode. This was tested using both agentless and EVP modes, verifying both that ITR was skipping tests properly, and that subsequent modifications which altered files would properly cause tests to run, and coverage events to be uploaded. Fixes CIAPP-7443 . ## Checklist - [x] Change(s) are motivated and described in the PR description. - [x] Testing strategy is described if automated tests are not included in the PR. - [x] Risk is outlined (performance impact, potential for breakage, maintainability, etc). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] [Library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) are followed. If no release note is required, add label `changelog/no-changelog`. - [x] Documentation is included (in-code, generated user docs, [public corp docs](https://github.com/DataDog/documentation/)). - [x] Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Title is accurate. - [x] No unnecessary changes are introduced. - [x] Description motivates each change. - [x] Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes unless absolutely necessary. - [x] Testing strategy adequately addresses listed risk(s). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] Release note makes sense to a user of the library. - [x] Reviewer has explicitly acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment. - [x] Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)
1 parent 70e2ada commit f37896d

File tree

6 files changed

+218
-14
lines changed

6 files changed

+218
-14
lines changed

ddtrace/internal/ci_visibility/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
EVP_PROXY_AGENT_ENDPOINT = "{}/api/v2/citestcycle".format(EVP_PROXY_AGENT_BASE_PATH)
3131
AGENTLESS_ENDPOINT = "api/v2/citestcycle"
3232
AGENTLESS_COVERAGE_ENDPOINT = "api/v2/citestcov"
33+
AGENTLESS_API_KEY_HEADER_NAME = "dd-api-key"
34+
AGENTLESS_APP_KEY_HEADER_NAME = "dd-application-key"
35+
EVP_NEEDS_APP_KEY_HEADER_NAME = "X-Datadog-NeedsAppKey"
36+
EVP_NEEDS_APP_KEY_HEADER_VALUE = "true"
3337
EVP_PROXY_COVERAGE_ENDPOINT = "{}/{}".format(EVP_PROXY_AGENT_BASE_PATH, AGENTLESS_COVERAGE_ENDPOINT)
3438
EVP_SUBDOMAIN_HEADER_API_VALUE = "api"
3539
EVP_SUBDOMAIN_HEADER_COVERAGE_VALUE = "citestcov-intake"

ddtrace/internal/ci_visibility/git_client.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@
2323
from .. import compat
2424
from ..utils.http import Response
2525
from ..utils.http import get_connection
26+
from .constants import AGENTLESS_API_KEY_HEADER_NAME
27+
from .constants import AGENTLESS_APP_KEY_HEADER_NAME
2628
from .constants import AGENTLESS_DEFAULT_SITE
29+
from .constants import EVP_NEEDS_APP_KEY_HEADER_NAME
30+
from .constants import EVP_NEEDS_APP_KEY_HEADER_VALUE
2731
from .constants import EVP_PROXY_AGENT_BASE_PATH
2832
from .constants import EVP_SUBDOMAIN_HEADER_API_VALUE
2933
from .constants import EVP_SUBDOMAIN_HEADER_NAME
@@ -134,9 +138,15 @@ def _search_commits(cls, requests_mode, base_url, repo_url, latest_commits, seri
134138
def _do_request(cls, requests_mode, base_url, endpoint, payload, serializer, headers=None):
135139
# type: (int, str, str, str, CIVisibilityGitClientSerializerV1, Optional[dict]) -> Response
136140
url = "{}/repository{}".format(base_url, endpoint)
137-
_headers = {"dd-api-key": serializer.api_key, "dd-application-key": serializer.app_key}
141+
_headers = {
142+
AGENTLESS_API_KEY_HEADER_NAME: serializer.api_key,
143+
AGENTLESS_APP_KEY_HEADER_NAME: serializer.app_key,
144+
}
138145
if requests_mode == REQUESTS_MODE.EVP_PROXY_EVENTS:
139-
_headers = {EVP_SUBDOMAIN_HEADER_NAME: EVP_SUBDOMAIN_HEADER_API_VALUE}
146+
_headers = {
147+
EVP_SUBDOMAIN_HEADER_NAME: EVP_SUBDOMAIN_HEADER_API_VALUE,
148+
EVP_NEEDS_APP_KEY_HEADER_NAME: EVP_NEEDS_APP_KEY_HEADER_VALUE,
149+
}
140150
if headers is not None:
141151
_headers.update(headers)
142152
try:

ddtrace/internal/ci_visibility/recorder.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@
2525
from ddtrace.provider import CIContextProvider
2626

2727
from .. import agent
28+
from .constants import AGENTLESS_API_KEY_HEADER_NAME
29+
from .constants import AGENTLESS_APP_KEY_HEADER_NAME
2830
from .constants import AGENTLESS_DEFAULT_SITE
31+
from .constants import EVP_NEEDS_APP_KEY_HEADER_NAME
32+
from .constants import EVP_NEEDS_APP_KEY_HEADER_VALUE
2933
from .constants import EVP_PROXY_AGENT_BASE_PATH
3034
from .constants import EVP_SUBDOMAIN_HEADER_API_VALUE
3135
from .constants import EVP_SUBDOMAIN_HEADER_EVENT_VALUE
@@ -168,21 +172,27 @@ def _should_collect_coverage(coverage_enabled_by_api):
168172

169173
def _check_enabled_features(self):
170174
# type: () -> Tuple[bool, bool]
171-
if not self._app_key:
172-
log.debug("Cannot make request to setting endpoint if application key is not set")
175+
# DEV: Remove this ``if`` once ITR is in GA
176+
if not ddconfig._ci_visibility_intelligent_testrunner_enabled:
173177
return False, False
174178

175-
_headers = {
176-
"dd-api-key": self._api_key,
177-
"dd-application-key": self._app_key,
178-
"Content-Type": "application/json",
179-
}
180-
181179
if self._requests_mode == REQUESTS_MODE.EVP_PROXY_EVENTS:
182180
url = get_trace_url() + EVP_PROXY_AGENT_BASE_PATH + SETTING_ENDPOINT
183-
_headers = {EVP_SUBDOMAIN_HEADER_NAME: EVP_SUBDOMAIN_HEADER_API_VALUE}
181+
_headers = {
182+
EVP_SUBDOMAIN_HEADER_NAME: EVP_SUBDOMAIN_HEADER_API_VALUE,
183+
EVP_NEEDS_APP_KEY_HEADER_NAME: EVP_NEEDS_APP_KEY_HEADER_VALUE,
184+
}
185+
log.debug("Making EVP request to agent: url=%s, headers=%s", url, _headers)
184186
elif self._requests_mode == REQUESTS_MODE.AGENTLESS_EVENTS:
187+
if not self._app_key or not self._api_key:
188+
log.debug("Cannot make request to setting endpoint if application key is not set")
189+
return False, False
185190
url = "https://api." + self._dd_site + SETTING_ENDPOINT
191+
_headers = {
192+
AGENTLESS_API_KEY_HEADER_NAME: self._api_key,
193+
AGENTLESS_APP_KEY_HEADER_NAME: self._app_key,
194+
"Content-Type": "application/json",
195+
}
186196
else:
187197
log.warning("Cannot make requests to setting endpoint if mode is not agentless or evp proxy")
188198
return False, False
@@ -314,8 +324,11 @@ def _fetch_tests_to_skip(self, skipping_mode):
314324
"Content-Type": "application/json",
315325
}
316326
if self._requests_mode == REQUESTS_MODE.EVP_PROXY_EVENTS:
317-
url = EVP_PROXY_AGENT_BASE_PATH + SKIPPABLE_ENDPOINT
318-
_headers = {EVP_SUBDOMAIN_HEADER_NAME: EVP_SUBDOMAIN_HEADER_API_VALUE}
327+
url = get_trace_url() + EVP_PROXY_AGENT_BASE_PATH + SKIPPABLE_ENDPOINT
328+
_headers = {
329+
EVP_SUBDOMAIN_HEADER_NAME: EVP_SUBDOMAIN_HEADER_API_VALUE,
330+
EVP_NEEDS_APP_KEY_HEADER_NAME: EVP_NEEDS_APP_KEY_HEADER_VALUE,
331+
}
319332
elif self._requests_mode == REQUESTS_MODE.AGENTLESS_EVENTS:
320333
url = "https://api." + self._dd_site + SKIPPABLE_ENDPOINT
321334
else:

docs/spelling_wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ mysql
142142
mysqlclient
143143
mysqldb
144144
namespace
145+
NeedsAppKey
145146
obfuscator
146147
openai
147148
opensearch
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
fixes:
2+
- |
3+
CI Visibility: fixes an issue where the Intelligent Test Runner would not work
4+
when in EVP proxy mode due to missing ``X-Datadog-NeedsAppKey`` header.

tests/ci_visibility/test_ci_visibility.py

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import os
23
import time
34

@@ -342,7 +343,10 @@ def test_get_client_do_request_evp_proxy_headers():
342343
)
343344

344345
_request.assert_called_once_with(
345-
"POST", "http://base_url/repository/endpoint", "payload", {"X-Datadog-EVP-Subdomain": "api"}
346+
"POST",
347+
"http://base_url/repository/endpoint",
348+
"payload",
349+
{"X-Datadog-EVP-Subdomain": "api", "X-Datadog-NeedsAppKey": "true"},
346350
)
347351

348352

@@ -417,6 +421,74 @@ def test_git_client_upload_packfiles(git_repo):
417421
assert call_kwargs["headers"] == {"Content-Type": "multipart/form-data; boundary=----------boundary------"}
418422

419423

424+
def test_git_do_request_agentless(git_repo):
425+
mock_serializer = CIVisibilityGitClientSerializerV1("fakeapikey", "fakeappkey")
426+
response = mock.MagicMock()
427+
setattr(response, "status", 200)
428+
429+
with mock.patch("ddtrace.internal.ci_visibility.git_client.get_connection") as mock_get_connection:
430+
with mock.patch("ddtrace.internal.compat.get_connection_response", return_value=response):
431+
mock_http_connection = mock.Mock()
432+
mock_http_connection.request = mock.Mock()
433+
mock_http_connection.close = mock.Mock()
434+
mock_get_connection.return_value = mock_http_connection
435+
436+
CIVisibilityGitClient._do_request(
437+
REQUESTS_MODE.AGENTLESS_EVENTS,
438+
"base_url",
439+
"endpoint",
440+
'{"payload": "payload"}',
441+
mock_serializer,
442+
{"mock_header_name": "mock_header_value"},
443+
)
444+
445+
mock_get_connection.assert_called_once_with("base_url/repositoryendpoint")
446+
mock_http_connection.request.assert_called_once_with(
447+
"POST",
448+
"base_url/repositoryendpoint",
449+
'{"payload": "payload"}',
450+
{
451+
"dd-api-key": "fakeapikey",
452+
"dd-application-key": "fakeappkey",
453+
"mock_header_name": "mock_header_value",
454+
},
455+
)
456+
457+
458+
def test_git_do_request_evp(git_repo):
459+
mock_serializer = CIVisibilityGitClientSerializerV1("foo", "bar")
460+
response = mock.MagicMock()
461+
setattr(response, "status", 200)
462+
463+
with mock.patch("ddtrace.internal.ci_visibility.git_client.get_connection") as mock_get_connection:
464+
with mock.patch("ddtrace.internal.compat.get_connection_response", return_value=response):
465+
mock_http_connection = mock.Mock()
466+
mock_http_connection.request = mock.Mock()
467+
mock_http_connection.close = mock.Mock()
468+
mock_get_connection.return_value = mock_http_connection
469+
470+
CIVisibilityGitClient._do_request(
471+
REQUESTS_MODE.EVP_PROXY_EVENTS,
472+
"base_url",
473+
"endpoint",
474+
'{"payload": "payload"}',
475+
mock_serializer,
476+
{"mock_header_name": "mock_header_value"},
477+
)
478+
479+
mock_get_connection.assert_called_once_with("base_url/repositoryendpoint")
480+
mock_http_connection.request.assert_called_once_with(
481+
"POST",
482+
"base_url/repositoryendpoint",
483+
'{"payload": "payload"}',
484+
{
485+
"X-Datadog-NeedsAppKey": "true",
486+
"X-Datadog-EVP-Subdomain": "api",
487+
"mock_header_name": "mock_header_value",
488+
},
489+
)
490+
491+
420492
def test_civisibilitywriter_agentless_url():
421493
with override_env(dict(DD_API_KEY="foobar.baz")):
422494
with override_global_config({"_ci_visibility_agentless_url": "https://foo.bar"}):
@@ -521,6 +593,106 @@ def test_civisibilitywriter_only_traces(_check_enabled_features):
521593
CIVisibility.disable()
522594

523595

596+
def test_civisibility_check_enabled_features_agentless_do_request_called_correctly():
597+
with mock.patch("ddtrace.internal.ci_visibility.recorder.uuid4", return_value="checkoutmyuuid4"):
598+
with mock.patch("ddtrace.internal.ci_visibility.recorder._do_request") as mock_do_request:
599+
mock_do_request.return_value = Response(
600+
status=200,
601+
body='{"data":{"id":"1234","type":"ci_app_tracers_test_service_settings","attributes":'
602+
'{"code_coverage":true,"tests_skipping":true}}}',
603+
)
604+
with mock.patch.object(CIVisibility, "__init__", return_value=None):
605+
mock_civisibilty = CIVisibility()
606+
607+
ddtrace.internal.ci_visibility.recorder.ddconfig = ddtrace.settings.Config()
608+
mock_civisibilty._requests_mode = REQUESTS_MODE.AGENTLESS_EVENTS
609+
mock_civisibilty._service = "service"
610+
mock_civisibilty._api_key = "myfakeapikey"
611+
mock_civisibilty._app_key = "myfakeappkey"
612+
mock_civisibilty._dd_site = "datad0g.com"
613+
mock_civisibilty._tags = {
614+
ci.git.REPOSITORY_URL: "my_repo_url",
615+
ci.git.COMMIT_SHA: "mycommitshaaaaaaalalala",
616+
ci.git.BRANCH: "notmain",
617+
}
618+
619+
enabled_features = mock_civisibilty._check_enabled_features()
620+
621+
mock_do_request.assert_called_once()
622+
do_request_call_args = mock_do_request.call_args[0]
623+
do_request_payload = json.loads(do_request_call_args[2])
624+
625+
assert do_request_call_args[0] == "POST"
626+
assert do_request_call_args[1] == "https://api.datad0g.com/api/v2/libraries/tests/services/setting"
627+
assert do_request_call_args[3] == {
628+
"dd-api-key": "myfakeapikey",
629+
"dd-application-key": "myfakeappkey",
630+
"Content-Type": "application/json",
631+
}
632+
assert do_request_payload == {
633+
"data": {
634+
"id": "checkoutmyuuid4",
635+
"type": "ci_app_test_service_libraries_settings",
636+
"attributes": {
637+
"service": "service",
638+
"env": None,
639+
"repository_url": "my_repo_url",
640+
"sha": "mycommitshaaaaaaalalala",
641+
"branch": "notmain",
642+
},
643+
}
644+
}
645+
assert enabled_features == (True, True)
646+
647+
648+
def test_civisibility_check_enabled_features_evp_do_request_called_correctly():
649+
with mock.patch("ddtrace.internal.ci_visibility.recorder.uuid4", return_value="checkoutmyuuid4"):
650+
with mock.patch("ddtrace.internal.ci_visibility.recorder._do_request") as mock_do_request:
651+
mock_do_request.return_value = Response(
652+
status=200,
653+
body='{"data":{"id":"1234","type":"ci_app_tracers_test_service_settings","attributes":'
654+
'{"code_coverage":true,"tests_skipping":true}}}',
655+
)
656+
with mock.patch.object(CIVisibility, "__init__", return_value=None):
657+
mock_civisibilty = CIVisibility()
658+
659+
ddtrace.internal.ci_visibility.recorder.ddconfig = ddtrace.settings.Config()
660+
mock_civisibilty._requests_mode = REQUESTS_MODE.EVP_PROXY_EVENTS
661+
mock_civisibilty._service = "service"
662+
mock_civisibilty._tags = {
663+
ci.git.REPOSITORY_URL: "my_repo_url",
664+
ci.git.COMMIT_SHA: "mycommitshaaaaaaalalala",
665+
ci.git.BRANCH: "notmain",
666+
}
667+
668+
enabled_features = mock_civisibilty._check_enabled_features()
669+
670+
mock_do_request.assert_called_once()
671+
do_request_call_args = mock_do_request.call_args[0]
672+
do_request_payload = json.loads(do_request_call_args[2])
673+
674+
assert do_request_call_args[0] == "POST"
675+
assert (
676+
do_request_call_args[1]
677+
== "http://localhost:8126/evp_proxy/v2/api/v2/libraries/tests/services/setting"
678+
)
679+
assert do_request_call_args[3] == {"X-Datadog-EVP-Subdomain": "api", "X-Datadog-NeedsAppKey": "true"}
680+
assert do_request_payload == {
681+
"data": {
682+
"id": "checkoutmyuuid4",
683+
"type": "ci_app_test_service_libraries_settings",
684+
"attributes": {
685+
"service": "service",
686+
"env": None,
687+
"repository_url": "my_repo_url",
688+
"sha": "mycommitshaaaaaaalalala",
689+
"branch": "notmain",
690+
},
691+
}
692+
}
693+
assert enabled_features == (True, True)
694+
695+
524696
@mock.patch("ddtrace.internal.ci_visibility.recorder._do_request")
525697
def test_civisibility_check_enabled_features_no_app_key_request_not_called(_do_request):
526698
with override_env(

0 commit comments

Comments
 (0)