Skip to content

Commit 57fc217

Browse files
fix(ci-visibility): make ITR enable properly in EVP mode [backport #6678 to 1.17] (#6717)
Backport 54f2493 from #6684 to 1.17. 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 8e06cd1 commit 57fc217

File tree

6 files changed

+220
-15
lines changed

6 files changed

+220
-15
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: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@
2424
from ddtrace.internal.writer.writer import Response
2525

2626
from .. import agent
27+
from .constants import AGENTLESS_API_KEY_HEADER_NAME
28+
from .constants import AGENTLESS_APP_KEY_HEADER_NAME
2729
from .constants import AGENTLESS_DEFAULT_SITE
30+
from .constants import EVP_NEEDS_APP_KEY_HEADER_NAME
31+
from .constants import EVP_NEEDS_APP_KEY_HEADER_VALUE
2832
from .constants import EVP_PROXY_AGENT_BASE_PATH
2933
from .constants import EVP_SUBDOMAIN_HEADER_API_VALUE
3034
from .constants import EVP_SUBDOMAIN_HEADER_EVENT_VALUE
@@ -171,24 +175,27 @@ def _should_collect_coverage(coverage_enabled_by_api):
171175

172176
def _check_enabled_features(self):
173177
# type: () -> Tuple[bool, bool]
174-
if not self._app_key:
175-
return False, False
176-
177178
# DEV: Remove this ``if`` once ITR is in GA
178179
if not ddconfig._ci_visibility_intelligent_testrunner_enabled:
179180
return False, False
180181

181-
_headers = {
182-
"dd-api-key": self._api_key,
183-
"dd-application-key": self._app_key,
184-
"Content-Type": "application/json",
185-
}
186-
187182
if self._requests_mode == REQUESTS_MODE.EVP_PROXY_EVENTS:
188183
url = get_trace_url() + EVP_PROXY_AGENT_BASE_PATH + SETTING_ENDPOINT
189-
_headers = {EVP_SUBDOMAIN_HEADER_NAME: EVP_SUBDOMAIN_HEADER_API_VALUE}
184+
_headers = {
185+
EVP_SUBDOMAIN_HEADER_NAME: EVP_SUBDOMAIN_HEADER_API_VALUE,
186+
EVP_NEEDS_APP_KEY_HEADER_NAME: EVP_NEEDS_APP_KEY_HEADER_VALUE,
187+
}
188+
log.debug("Making EVP request to agent: url=%s, headers=%s", url, _headers)
190189
elif self._requests_mode == REQUESTS_MODE.AGENTLESS_EVENTS:
190+
if not self._app_key or not self._api_key:
191+
log.debug("Cannot make request to setting endpoint if application key is not set")
192+
return False, False
191193
url = "https://api." + self._dd_site + SETTING_ENDPOINT
194+
_headers = {
195+
AGENTLESS_API_KEY_HEADER_NAME: self._api_key,
196+
AGENTLESS_APP_KEY_HEADER_NAME: self._app_key,
197+
"Content-Type": "application/json",
198+
}
192199
else:
193200
log.warning("Cannot make requests to setting endpoint if mode is not agentless or evp proxy")
194201
return False, False
@@ -292,8 +299,11 @@ def _fetch_tests_to_skip(self):
292299
"Content-Type": "application/json",
293300
}
294301
if self._requests_mode == REQUESTS_MODE.EVP_PROXY_EVENTS:
295-
url = EVP_PROXY_AGENT_BASE_PATH + SKIPPABLE_ENDPOINT
296-
_headers = {EVP_SUBDOMAIN_HEADER_NAME: EVP_SUBDOMAIN_HEADER_API_VALUE}
302+
url = get_trace_url() + EVP_PROXY_AGENT_BASE_PATH + SKIPPABLE_ENDPOINT
303+
_headers = {
304+
EVP_SUBDOMAIN_HEADER_NAME: EVP_SUBDOMAIN_HEADER_API_VALUE,
305+
EVP_NEEDS_APP_KEY_HEADER_NAME: EVP_NEEDS_APP_KEY_HEADER_VALUE,
306+
}
297307
elif self._requests_mode == REQUESTS_MODE.AGENTLESS_EVENTS:
298308
url = "https://api." + self._dd_site + SKIPPABLE_ENDPOINT
299309
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: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,10 @@ def test_get_client_do_request_evp_proxy_headers():
355355
)
356356

357357
_request.assert_called_once_with(
358-
"POST", "http://base_url/repository/endpoint", "payload", {"X-Datadog-EVP-Subdomain": "api"}
358+
"POST",
359+
"http://base_url/repository/endpoint",
360+
"payload",
361+
{"X-Datadog-EVP-Subdomain": "api", "X-Datadog-NeedsAppKey": "true"},
359362
)
360363

361364

@@ -430,6 +433,74 @@ def test_git_client_upload_packfiles(git_repo):
430433
assert call_kwargs["headers"] == {"Content-Type": "multipart/form-data; boundary=----------boundary------"}
431434

432435

436+
def test_git_do_request_agentless(git_repo):
437+
mock_serializer = CIVisibilityGitClientSerializerV1("fakeapikey", "fakeappkey")
438+
response = mock.MagicMock()
439+
setattr(response, "status", 200)
440+
441+
with mock.patch("ddtrace.internal.ci_visibility.git_client.get_connection") as mock_get_connection:
442+
with mock.patch("ddtrace.internal.compat.get_connection_response", return_value=response):
443+
mock_http_connection = mock.Mock()
444+
mock_http_connection.request = mock.Mock()
445+
mock_http_connection.close = mock.Mock()
446+
mock_get_connection.return_value = mock_http_connection
447+
448+
CIVisibilityGitClient._do_request(
449+
REQUESTS_MODE.AGENTLESS_EVENTS,
450+
"base_url",
451+
"endpoint",
452+
'{"payload": "payload"}',
453+
mock_serializer,
454+
{"mock_header_name": "mock_header_value"},
455+
)
456+
457+
mock_get_connection.assert_called_once_with("base_url/repositoryendpoint")
458+
mock_http_connection.request.assert_called_once_with(
459+
"POST",
460+
"base_url/repositoryendpoint",
461+
'{"payload": "payload"}',
462+
{
463+
"dd-api-key": "fakeapikey",
464+
"dd-application-key": "fakeappkey",
465+
"mock_header_name": "mock_header_value",
466+
},
467+
)
468+
469+
470+
def test_git_do_request_evp(git_repo):
471+
mock_serializer = CIVisibilityGitClientSerializerV1("foo", "bar")
472+
response = mock.MagicMock()
473+
setattr(response, "status", 200)
474+
475+
with mock.patch("ddtrace.internal.ci_visibility.git_client.get_connection") as mock_get_connection:
476+
with mock.patch("ddtrace.internal.compat.get_connection_response", return_value=response):
477+
mock_http_connection = mock.Mock()
478+
mock_http_connection.request = mock.Mock()
479+
mock_http_connection.close = mock.Mock()
480+
mock_get_connection.return_value = mock_http_connection
481+
482+
CIVisibilityGitClient._do_request(
483+
REQUESTS_MODE.EVP_PROXY_EVENTS,
484+
"base_url",
485+
"endpoint",
486+
'{"payload": "payload"}',
487+
mock_serializer,
488+
{"mock_header_name": "mock_header_value"},
489+
)
490+
491+
mock_get_connection.assert_called_once_with("base_url/repositoryendpoint")
492+
mock_http_connection.request.assert_called_once_with(
493+
"POST",
494+
"base_url/repositoryendpoint",
495+
'{"payload": "payload"}',
496+
{
497+
"X-Datadog-NeedsAppKey": "true",
498+
"X-Datadog-EVP-Subdomain": "api",
499+
"mock_header_name": "mock_header_value",
500+
},
501+
)
502+
503+
433504
def test_civisibilitywriter_agentless_url():
434505
with override_env(dict(DD_API_KEY="foobar.baz")):
435506
with override_global_config({"_ci_visibility_agentless_url": "https://foo.bar"}):
@@ -531,6 +602,111 @@ def test_civisibilitywriter_only_traces():
531602
CIVisibility.disable()
532603

533604

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

0 commit comments

Comments
 (0)