Skip to content

Commit d02755c

Browse files
authored
feat(ci_visibility): bazel offline cache and payload-file modes (#17197)
## Description Adds Bazel-focused CI Visibility support with two offline execution modes, mirroring the Go implementation in DataDog/dd-trace-go#4503: - **Manifest mode** (`DD_TEST_OPTIMIZATION_MANIFEST_FILE`): reads settings, known tests, and test management data from pre-fetched JSON cache files inside `.testoptimization/`, enabling CI Visibility in Bazel's hermetic sandbox without network access. - **Payload-files mode** (`DD_TEST_OPTIMIZATION_PAYLOADS_IN_FILES`): writes test event, coverage, and telemetry payloads as JSON files to `TEST_UNDECLARED_OUTPUTS_DIR/payloads/{tests,coverage,telemetry}/` instead of sending HTTP requests. ### Key changes - **`offline_mode.py`**: `OfflineMode` singleton detects and validates both modes from env vars. Manifest version parsing supports plain `"1"` and `version=1` assignment syntax (matching Go). Runfiles resolution via `RUNFILES_DIR`, `RUNFILES_MANIFEST_FILE`, and `TEST_SRCDIR`. - **`cached_file_provider.py`**: `CachedFileDataProvider` implements the `TestOptDataProvider` protocol, reading from cache files. Skippable tests return empty unconditionally (hard no-op in manifest mode, matching Go). - **`writer.py`**: `TestOptWriter` and `TestCoverageWriter` intercept `_send_events` in payload-files mode to write JSON files. Filenames use `{kind}-{timestamp}-{pid}-{seq}.json` pattern matching Go's DDTestRunner expectations. Telemetry files use ordinal-first naming (`telemetry-{seq_padded}-{pid}.json`) for deterministic replay. - **`telemetry.py`**: `TelemetryAPI` accumulates CI Visibility metrics in payload-files mode and writes them to `payloads/telemetry/` on `finish()`, matching Go's telemetry file-sink behavior. - **`session_manager.py`**: Swaps `APIClient` for `CachedFileDataProvider` in manifest mode. Uses `NoOpBackendConnectorSetup` for writers/telemetry. Forces test skipping off in manifest mode. Skips git data upload in both offline modes. - **`env_tags.py`**: In payload-files mode, reads CI/Git tags from `DD_TEST_OPTIMIZATION_ENV_DATA_FILE` instead of invoking git CLI. Falls back to `ci.provider.name = "bazel"` when no other provider is detected. - **`constants.py`**: New `DD_TEST_OPTIMIZATION_ENV_DATA_FILE` constant. ## Testing - Unit tests added/updated across 4 test files covering all new functionality: - `test_offline_mode.py` — manifest version parsing (plain, assignment syntax, blank lines, invalid), runfiles resolution, `OfflineMode` initialization - `test_cached_file_provider.py` — cache reading, skippable tests hard no-op - `test_payload_files.py` — payload file naming (`{kind}-{ts}-{pid}-{seq}.json`), telemetry ordinal naming, telemetry file output on `TelemetryAPI.finish()` - `test_bazel_offline_session_manager.py` — provider selection, git upload skipping, env data file reading, bazel provider fallback, skipping forced off in manifest mode - All 90 tests pass on Python 3.12 with pytest ~=8.0. ## Risks - **Payload file naming**: Changed from `payload_<n>.json` to `{kind}-{ts}-{pid}-{seq}.json` to match Go's DDTestRunner expectations. Any consumer that relied on the old naming would need updating. - Manifest mode disables test skipping unconditionally — this is intentional and matches Go behavior. ## Additional Notes - Mirrors Go PR: DataDog/dd-trace-go#4503 - Features not ported (not applicable to Python's architecture): per-span CI/git tag stripping (Python sets tags at metadata level), impacted tests suppression (no such concept in Python yet), low-level git CLI guard (only called from guarded `upload_git_data`), CI log shipping suppression (Python plugin has no log shipping). Co-authored-by: federico.mon <federico.mon@datadoghq.com>
1 parent 57eb721 commit d02755c

16 files changed

Lines changed: 2104 additions & 18 deletions
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""
2+
File-based data provider for Bazel offline (manifest) mode.
3+
4+
Defines the ``TestOptDataProvider`` Protocol that both ``APIClient`` and
5+
``CachedFileDataProvider`` satisfy, so ``SessionManager`` can swap between
6+
HTTP and file-based data fetching without branching inside each method.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import logging
13+
import os
14+
from pathlib import Path
15+
import typing as t
16+
17+
from ddtrace.testing.internal.constants import ITRSkippingLevel
18+
from ddtrace.testing.internal.settings_data import Settings
19+
from ddtrace.testing.internal.settings_data import TestProperties
20+
from ddtrace.testing.internal.telemetry import TelemetryAPI
21+
from ddtrace.testing.internal.test_data import ModuleRef
22+
from ddtrace.testing.internal.test_data import SuiteRef
23+
from ddtrace.testing.internal.test_data import TestRef
24+
25+
26+
log = logging.getLogger(__name__)
27+
28+
29+
class TestOptDataProvider(t.Protocol):
30+
"""
31+
Protocol satisfied by both ``APIClient`` (HTTP) and ``CachedFileDataProvider`` (files).
32+
33+
``SessionManager`` types its ``api_client`` attribute as this Protocol so
34+
mypy catches interface drift between the two implementations.
35+
"""
36+
37+
def get_settings(self) -> Settings: ...
38+
39+
def get_known_tests(self) -> set[TestRef]: ...
40+
41+
def get_test_management_properties(self) -> dict[TestRef, TestProperties]: ...
42+
43+
def get_skippable_tests(self) -> tuple[set[t.Union[SuiteRef, TestRef]], t.Optional[str]]: ...
44+
45+
def get_known_commits(self, latest_commits: list[str]) -> t.Optional[list[str]]: ...
46+
47+
def send_git_pack_file(self, packfile: Path) -> t.Optional[int]: ...
48+
49+
def upload_coverage_report(
50+
self,
51+
coverage_report_bytes: bytes,
52+
coverage_format: str,
53+
tags: t.Optional[dict[str, str]],
54+
) -> bool: ...
55+
56+
def close(self) -> None: ...
57+
58+
59+
def _read_cache_json(cache_path: str) -> t.Optional[t.Any]:
60+
"""
61+
Read and parse a JSON file from the .testoptimization cache directory.
62+
63+
Returns the parsed object on success, or None if the file is missing or
64+
unreadable. A missing file is treated as an empty response — no HTTP
65+
fallback is attempted (Bazel hermeticity requires this hard boundary).
66+
"""
67+
try:
68+
with open(cache_path) as f:
69+
return json.load(f)
70+
except FileNotFoundError:
71+
log.debug("Cache file not found: %s — treating as empty response", cache_path)
72+
return None
73+
except (OSError, json.JSONDecodeError) as e:
74+
log.warning("Error reading cache file %s: %s — treating as empty response", cache_path, e)
75+
return None
76+
77+
78+
class CachedFileDataProvider:
79+
"""
80+
Reads test optimization data from pre-fetched JSON files in the
81+
.testoptimization directory (Bazel manifest mode).
82+
83+
All four fetch methods mirror the structure of the corresponding backend
84+
HTTP responses so the same parsing code applies. Methods that are only
85+
reachable via ``upload_git_data`` (which is already guarded to skip in
86+
offline mode) are implemented as no-ops.
87+
"""
88+
89+
def __init__(
90+
self,
91+
test_optimization_dir: str,
92+
itr_skipping_level: ITRSkippingLevel,
93+
telemetry_api: TelemetryAPI,
94+
) -> None:
95+
self._dir = test_optimization_dir
96+
self._itr_skipping_level = itr_skipping_level
97+
self._telemetry_api = telemetry_api
98+
99+
def _cache_path(self, relative: str) -> str:
100+
return os.path.join(self._dir, *relative.split("/"))
101+
102+
def get_settings(self) -> Settings:
103+
cached = _read_cache_json(self._cache_path("cache/http/settings.json"))
104+
if cached is None:
105+
log.debug("No cached settings file — all features disabled in manifest mode")
106+
return Settings()
107+
try:
108+
settings = Settings.from_attributes(cached["data"]["attributes"])
109+
except Exception as e:
110+
log.warning("Error parsing cached settings file: %s — all features disabled", e)
111+
return Settings()
112+
self._telemetry_api.record_settings(settings)
113+
return settings
114+
115+
def get_known_tests(self) -> set[TestRef]:
116+
cached = _read_cache_json(self._cache_path("cache/http/known_tests.json"))
117+
if cached is None:
118+
return set()
119+
try:
120+
known: set[TestRef] = set()
121+
for module, suites in cached["data"]["attributes"]["tests"].items():
122+
module_ref = ModuleRef(module)
123+
for suite, tests in suites.items():
124+
suite_ref = SuiteRef(module_ref, suite)
125+
for test in tests:
126+
known.add(TestRef(suite_ref, test))
127+
self._telemetry_api.record_known_tests_count(len(known))
128+
return known
129+
except Exception as e:
130+
log.warning("Error parsing cached known tests file: %s", e)
131+
return set()
132+
133+
def get_test_management_properties(self) -> dict[TestRef, TestProperties]:
134+
cached = _read_cache_json(self._cache_path("cache/http/test_management.json"))
135+
if cached is None:
136+
return {}
137+
try:
138+
props: dict[TestRef, TestProperties] = {}
139+
for module_name, module_data in cached["data"]["attributes"]["modules"].items():
140+
module_ref = ModuleRef(module_name)
141+
for suite_name, suite_data in module_data["suites"].items():
142+
suite_ref = SuiteRef(module_ref, suite_name)
143+
for test_name, test_data in suite_data["tests"].items():
144+
p = test_data.get("properties", {})
145+
props[TestRef(suite_ref, test_name)] = TestProperties(
146+
quarantined=p.get("quarantined", False),
147+
disabled=p.get("disabled", False),
148+
attempt_to_fix=p.get("attempt_to_fix", False),
149+
)
150+
self._telemetry_api.record_test_management_tests_count(len(props))
151+
return props
152+
except Exception as e:
153+
log.warning("Error parsing cached test management file: %s", e)
154+
return {}
155+
156+
def get_skippable_tests(self) -> tuple[set[t.Union[SuiteRef, TestRef]], t.Optional[str]]:
157+
# Hard no-op in manifest mode: skippable tests are not applied in hermetic
158+
# Bazel runs. This matches the Go implementation which returns an empty set
159+
# without reading the cache file.
160+
return set(), None
161+
162+
# --- no-ops for methods unreachable in manifest mode ---
163+
164+
def get_known_commits(self, latest_commits: list[str]) -> list[str]:
165+
return [] # upload_git_data() returns early in manifest mode
166+
167+
def send_git_pack_file(self, packfile: Path) -> t.Optional[int]:
168+
return None # upload_git_data() returns early in manifest mode
169+
170+
def upload_coverage_report(
171+
self,
172+
coverage_report_bytes: bytes,
173+
coverage_format: str,
174+
tags: t.Optional[dict[str, str]] = None,
175+
) -> bool:
176+
return False # coverage upload is skipped in payload-files mode
177+
178+
def close(self) -> None:
179+
pass

ddtrace/testing/internal/constants.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,12 @@ class ITRSkippingLevel(Enum):
1818
TAG_FALSE = "false"
1919

2020
EMPTY_NAME = "."
21+
22+
# Bazel / offline mode environment variables
23+
DD_TEST_OPTIMIZATION_MANIFEST_FILE = "DD_TEST_OPTIMIZATION_MANIFEST_FILE"
24+
DD_TEST_OPTIMIZATION_PAYLOADS_IN_FILES = "DD_TEST_OPTIMIZATION_PAYLOADS_IN_FILES"
25+
DD_TEST_OPTIMIZATION_ENV_DATA_FILE = "DD_TEST_OPTIMIZATION_ENV_DATA_FILE"
26+
TEST_UNDECLARED_OUTPUTS_DIR = "TEST_UNDECLARED_OUTPUTS_DIR"
27+
28+
# The only supported .testoptimization manifest version
29+
SUPPORTED_MANIFEST_VERSION = 1

ddtrace/testing/internal/env_tags.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
1+
import json
2+
import logging
13
import os
24
import typing as t
35

46
from ddtrace.internal.settings import env
57
from ddtrace.testing.internal import ci
68
from ddtrace.testing.internal import git
79
from ddtrace.testing.internal.ci import CITag
10+
from ddtrace.testing.internal.constants import DD_TEST_OPTIMIZATION_ENV_DATA_FILE
811
from ddtrace.testing.internal.git import GitTag
912
from ddtrace.testing.internal.git import get_workspace_path
13+
from ddtrace.testing.internal.offline_mode import get_offline_mode
14+
from ddtrace.testing.internal.offline_mode import resolve_rlocation
1015
from ddtrace.testing.internal.utils import _filter_sensitive_info
1116

1217

18+
log = logging.getLogger(__name__)
19+
20+
1321
_TagDict = dict[str, t.Optional[str]]
1422

1523

@@ -26,7 +34,42 @@ def merge_tags(target: _TagDict, *tag_dicts: _TagDict) -> None:
2634
target[k] = v
2735

2836

37+
def _read_env_data_file() -> dict[str, str]:
38+
"""Read CI/Git tags from the environmental data file if available.
39+
40+
The Bazel rule provides pre-computed CI and Git context via
41+
``DD_TEST_OPTIMIZATION_ENV_DATA_FILE``. This replaces local Git CLI
42+
enrichment in payload-files mode.
43+
"""
44+
45+
path = env.get(DD_TEST_OPTIMIZATION_ENV_DATA_FILE)
46+
if not path:
47+
return {}
48+
path = resolve_rlocation(path)
49+
try:
50+
with open(path) as f:
51+
data = json.load(f)
52+
if isinstance(data, dict):
53+
return {k: v for k, v in data.items() if isinstance(k, str) and isinstance(v, str)}
54+
except (OSError, json.JSONDecodeError, ValueError) as e:
55+
log.warning("Error reading env data file %s: %s", path, e)
56+
return {}
57+
58+
2959
def get_env_tags() -> dict[str, str]:
60+
# NOTE: In payload-files mode (Bazel sandbox output), CI/Git/OS/runtime tags
61+
# must NOT be populated from the local environment or git CLI. Instead, the
62+
# Bazel rule provides pre-computed context via DD_TEST_OPTIMIZATION_ENV_DATA_FILE.
63+
64+
offline = get_offline_mode()
65+
if offline.payload_files_enabled:
66+
log.debug("Payload-files mode active: reading tags from env data file instead of local git")
67+
env_data_tags = _read_env_data_file()
68+
# Bazel provider fallback: if no CI provider was detected, tag as "bazel"
69+
if CITag.PROVIDER_NAME not in env_data_tags:
70+
env_data_tags[CITag.PROVIDER_NAME] = "bazel"
71+
return env_data_tags
72+
3073
tags: _TagDict = {}
3174

3275
merge_tags(
@@ -53,6 +96,10 @@ def get_env_tags() -> dict[str, str]:
5396
if job_id := env.get("JOB_ID"):
5497
tags[CITag.JOB_ID] = job_id
5598

99+
# Bazel provider fallback (manifest-only mode without payload-files)
100+
if offline.manifest_enabled and not tags.get(CITag.PROVIDER_NAME):
101+
tags[CITag.PROVIDER_NAME] = "bazel"
102+
56103
return {k: v for k, v in tags.items() if v}
57104

58105

ddtrace/testing/internal/http.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,57 @@ class FileAttachment:
487487
data: bytes
488488

489489

490+
class NoOpBackendConnector:
491+
"""
492+
A connector that makes no network requests.
493+
494+
Used when the plugin is running in Bazel's hermetic sandbox (manifest mode
495+
active), where network access is unavailable. Any call to ``request()`` or
496+
its helpers is silently discarded and an empty ``BackendResult`` is returned.
497+
Writers and the telemetry API receive this connector but their event
498+
delivery is handled via the payload-files code path instead.
499+
"""
500+
501+
def close(self) -> None:
502+
pass
503+
504+
def request(
505+
self,
506+
method: str,
507+
path: str,
508+
data: t.Optional[bytes] = None,
509+
headers: t.Optional[dict[str, str]] = None,
510+
send_gzip: bool = False,
511+
is_json_response: bool = False,
512+
telemetry: t.Any = None,
513+
max_attempts: int = 1,
514+
) -> BackendResult:
515+
log.debug("NoOp connector: skipping %s %s in offline mode", method, path)
516+
return BackendResult()
517+
518+
def get_json(self, path: str, **kwargs: t.Any) -> BackendResult:
519+
return BackendResult()
520+
521+
def post_json(self, path: str, data: t.Any, **kwargs: t.Any) -> BackendResult:
522+
return BackendResult()
523+
524+
def post_files(self, path: str, files: t.Any, **kwargs: t.Any) -> BackendResult:
525+
return BackendResult()
526+
527+
528+
class NoOpBackendConnectorSetup(BackendConnectorSetup):
529+
"""
530+
A connector setup for fully offline (Bazel sandbox) mode.
531+
532+
Returns ``NoOpBackendConnector`` instances for all subdomains so that no
533+
network requests are attempted. ``default_env`` falls back to the standard
534+
default because there is no agent to query.
535+
"""
536+
537+
def get_connector_for_subdomain(self, subdomain: Subdomain) -> "NoOpBackendConnector": # type: ignore[override]
538+
return NoOpBackendConnector()
539+
540+
490541
class UnixDomainSocketHTTPConnection(http.client.HTTPConnection):
491542
"""An HTTP connection established over a Unix Domain Socket."""
492543

0 commit comments

Comments
 (0)