Skip to content

Commit c821d80

Browse files
Align agentless mock with the UFC CDN response (#7315)
1 parent 492a53f commit c821d80

4 files changed

Lines changed: 64 additions & 18 deletions

File tree

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
/utils/build/docker/vcr/cassettes/aiguard @DataDog/k9-ai-guard @DataDog/system-tests-core
1515
/utils/scripts/generate-ai-guard-cassettes.sh @DataDog/k9-ai-guard @DataDog/system-tests-core
1616
/utils/docker_fixtures/spec/llm_observability.py @DataDog/ml-observability @DataDog/system-tests-core
17+
/utils/docker_fixtures/_mock_ffe_agentless_backend.py @DataDog/feature-flagging-and-experimentation-sdk @DataDog/system-tests-core
1718
/utils/telemetry/intake/ @DataDog/apm-sdk-capabilities @DataDog/system-tests-core
1819
/utils/telemetry/intake/static/ @DataDog/apm-sdk
1920

2021
/tests/ffe/ @DataDog/feature-flagging-and-experimentation-sdk @DataDog/system-tests-core
22+
/tests/test_the_test/test_mock_ffe_agentless_backend.py @DataDog/feature-flagging-and-experimentation-sdk @DataDog/system-tests-core
2123
/tests/parametric/ @DataDog/system-tests-core @DataDog/apm-sdk-capabilities
2224
/tests/parametric/test_config_consistency.py @DataDog/apm-sdk-capabilities
2325
/tests/parametric/test_ffe/ @DataDog/feature-flagging-and-experimentation-sdk @DataDog/system-tests-core

tests/test_library_conf.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,8 +445,11 @@ def retrieve_span_links(span: DataDogAgentSpan) -> list[dict] | None:
445445
if span_meta.get("_dd.span_links") is None:
446446
return None
447447

448-
# Convert span_links tags into msgpack v0.4 format
449-
json_links = json.loads(span_meta["_dd.span_links"])
448+
# Convert span_links tags into msgpack v0.4 format. The v1 trace proxy
449+
# normalizes JSON meta values while v0.4 payloads retain the serialized
450+
# string, so accept both representations here.
451+
raw_links = span_meta["_dd.span_links"]
452+
json_links = json.loads(raw_links) if isinstance(raw_links, (str, bytes, bytearray)) else raw_links
450453
links = []
451454
for json_link in json_links:
452455
link = {}

tests/test_the_test/test_mock_ffe_agentless_backend.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,39 @@
77
from utils.docker_fixtures._core import HOST_GATEWAY_EXTRA_HOSTS, extra_hosts_for_environment
88
from utils.docker_fixtures._mock_ffe_agentless_backend import (
99
CONFIG_PATH,
10+
CONFIG_QUERY,
1011
EXPECTED_API_KEY,
12+
EXPECTED_DD_ENV,
1113
MockFFEAgentlessBackendServer,
14+
UFC_RESPONSE_TYPE,
1215
)
1316

1417

1518
@scenarios.test_the_test
1619
@features.not_reported
1720
def test_mock_ffe_agentless_backend_serves_fixture_and_tracks_metadata(worker_id: str) -> None:
18-
server = MockFFEAgentlessBackendServer(worker_id)
21+
server = MockFFEAgentlessBackendServer(worker_id, port=0)
1922
try:
20-
response = requests.get(server.base_url + CONFIG_PATH, headers={"dd-api-key": EXPECTED_API_KEY}, timeout=5)
23+
for invalid_query in ("", "?dd_env=", "?dd_env=wrong", f"?dd_env={EXPECTED_DD_ENV}&dd_env=wrong"):
24+
response = requests.get(
25+
server.base_url + CONFIG_PATH + invalid_query,
26+
headers={"DD-API-KEY": EXPECTED_API_KEY},
27+
timeout=5,
28+
)
29+
assert response.status_code == 404
30+
31+
response = requests.get(
32+
f"{server.base_url}{CONFIG_PATH}?{CONFIG_QUERY}",
33+
headers={"DD-API-KEY": EXPECTED_API_KEY},
34+
timeout=5,
35+
)
2136
response.raise_for_status()
2237

38+
payload = response.json()
39+
assert payload["data"]["type"] == UFC_RESPONSE_TYPE
40+
assert payload["data"]["attributes"]["environment"]["name"] == "Test"
41+
assert "new-user-onboarding" in payload["data"]["attributes"]["flags"]
42+
2343
status = server.status()
2444
assert status["requests_total"] == 1
2545
assert status["last_auth_present"] is True
@@ -37,8 +57,9 @@ def test_mock_ffe_agentless_backend_host_gateway_mapping(monkeypatch: pytest.Mon
3757
monkeypatch.delenv("SYSTEM_TESTS_MOCK_FFE_AGENTLESS_BACKEND_HOST", raising=False)
3858
monkeypatch.delenv("SYSTEM_TESTS_MOCK_AGENTLESS_BACKEND_HOST", raising=False)
3959

40-
server = MockFFEAgentlessBackendServer(worker_id)
60+
server = MockFFEAgentlessBackendServer(worker_id, port=0)
4161
try:
62+
assert server.library_config_url.endswith(f"{CONFIG_PATH}?{CONFIG_QUERY}")
4263
env = {"DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL": server.library_config_url}
4364
assert extra_hosts_for_environment(env) == HOST_GATEWAY_EXTRA_HOSTS
4465
finally:
@@ -48,7 +69,7 @@ def test_mock_ffe_agentless_backend_host_gateway_mapping(monkeypatch: pytest.Mon
4869
@scenarios.test_the_test
4970
@features.not_reported
5071
def test_mock_ffe_agentless_backend_status_is_metadata_only(worker_id: str) -> None:
51-
server = MockFFEAgentlessBackendServer(worker_id)
72+
server = MockFFEAgentlessBackendServer(worker_id, port=0)
5273
try:
5374
status = server.status()
5475
assert set(status) == {

utils/docker_fixtures/_mock_ffe_agentless_backend.py

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import threading
1818
import time
1919
from typing import TYPE_CHECKING, Any, TypedDict, cast
20-
from urllib.parse import urlparse
20+
from urllib.parse import parse_qs, urlencode, urlparse
2121

2222
import pytest
2323
import requests
@@ -44,10 +44,13 @@
4444
DELAYED_RESPONSE_SECONDS = 0.5
4545
TIMEOUT_RESPONSE_SECONDS = 1.5
4646
MAX_CONTROL_BODY_BYTES = 512
47-
CONFIG_PATH = "/api/v2/feature-flagging/config/server-distribution"
47+
CONFIG_PATH = "/api/v2/feature-flagging/config/rules-based/server"
48+
EXPECTED_DD_ENV = "test"
49+
CONFIG_QUERY = urlencode({"dd_env": EXPECTED_DD_ENV})
4850
REPO_ROOT = Path(__file__).parents[2]
4951
UFC_FIXTURE_PATH = REPO_ROOT / "tests" / "parametric" / "test_ffe" / "flags-v1.json"
5052
MALFORMED_UFC_BYTES = b'{"flags": ['
53+
UFC_RESPONSE_TYPE = "universal-flag-configuration"
5154

5255

5356
class MockFFEAgentlessBackendStatus(TypedDict):
@@ -143,18 +146,20 @@ def __init__(self, server_address: tuple[str, int]) -> None:
143146

144147
class MockFFEAgentlessBackendRequestHandler(BaseHTTPRequestHandler):
145148
# Endpoint contract:
146-
# - GET /api/v2/feature-flagging/config/server-distribution
149+
# - GET /api/v2/feature-flagging/config/rules-based/server?dd_env=test
147150
# - GET /status
148151
# - POST /control/responses
149152
# - POST /control/reset
150153
server: MockFFEAgentlessBackendHTTPServer
151154

152155
def do_GET(self) -> None:
153-
path = urlparse(self.path).path
154-
if path == CONFIG_PATH:
156+
parsed = urlparse(self.path)
157+
if parsed.path == CONFIG_PATH and parse_qs(parsed.query, keep_blank_values=True) == {
158+
"dd_env": [EXPECTED_DD_ENV]
159+
}:
155160
self._handle_config()
156161
return
157-
if path == "/status":
162+
if parsed.path == "/status":
158163
self._write_json(HTTPStatus.OK, self.server.state.status())
159164
return
160165
self._write_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
@@ -232,11 +237,20 @@ def _write_json(self, status_code: HTTPStatus, payload: dict[str, Any] | MockFFE
232237

233238
def _has_auth(headers: Mapping[str, str]) -> bool:
234239
normalized = {key.lower(): value for key, value in headers.items()}
235-
return any(normalized.get(header) == EXPECTED_API_KEY for header in ("dd-api-key", "x-datadog-api-key"))
240+
return normalized.get("dd-api-key") == EXPECTED_API_KEY
236241

237242

238243
def _valid_ufc_bytes() -> bytes:
239-
return UFC_FIXTURE_PATH.read_bytes()
244+
attributes = json.loads(UFC_FIXTURE_PATH.read_text())
245+
return json.dumps(
246+
{
247+
"data": {
248+
"id": "1",
249+
"type": UFC_RESPONSE_TYPE,
250+
"attributes": attributes,
251+
}
252+
}
253+
).encode("utf-8")
240254

241255

242256
def validate_responses(responses: object) -> list[str]:
@@ -269,13 +283,19 @@ def _response_for_response(response: str, *, has_auth: bool) -> tuple[int, bytes
269283

270284

271285
def _strip_config_path(url: str) -> str:
272-
return url.removesuffix(CONFIG_PATH)
286+
parsed = urlparse(url)
287+
if not parsed.path.endswith(CONFIG_PATH):
288+
return url
289+
290+
base_path = parsed.path.removesuffix(CONFIG_PATH).rstrip("/")
291+
return parsed._replace(path=base_path, params="", query="", fragment="").geturl().rstrip("/")
273292

274293

275294
class MockFFEAgentlessBackendServer:
276-
def __init__(self, worker_id: str) -> None:
277-
self.port = get_host_port(worker_id, 4900)
295+
def __init__(self, worker_id: str, *, port: int | None = None) -> None:
296+
self.port = get_host_port(worker_id, 4900) if port is None else port
278297
self._server = MockFFEAgentlessBackendHTTPServer(("0.0.0.0", self.port)) # noqa: S104 - test fixture must be container-reachable.
298+
self.port = self._server.server_port
279299
self._thread = threading.Thread(
280300
target=self._server.serve_forever, name="mock-ffe-agentless-backend", daemon=True
281301
)
@@ -300,7 +320,7 @@ def library_base_url(self) -> str:
300320

301321
@property
302322
def library_config_url(self) -> str:
303-
return f"{self.library_base_url}{CONFIG_PATH}"
323+
return f"{self.library_base_url}{CONFIG_PATH}?{CONFIG_QUERY}"
304324

305325
def reset(self) -> None:
306326
response = requests.post(f"{self.base_url}/control/reset", timeout=5)

0 commit comments

Comments
 (0)