diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7d8e1c0..626c9330 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,7 +163,7 @@ jobs: - name: Install Python tooling dependencies shell: bash - run: python3 -m pip install -r tools/requirements.txt + run: python3 -m pip install --require-hashes -r tools/requirements.txt - name: Verify Python tooling tests through Bazel shell: bash @@ -376,12 +376,16 @@ jobs: - name: Install Python tooling dependencies shell: bash - run: python3 -m pip install -r tools/requirements.txt + run: python3 -m pip install --require-hashes -r tools/requirements.txt - name: Verify core/go module versions are aligned shell: bash run: python3 tools/dev/check_module_versions.py + - name: Verify .bazelversion parity + shell: bash + run: python3 tools/dev/check_bazelversion_sync.py + - name: Validate integration fixture and snapshot JSON shell: bash run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b110d9f8..2e700bcd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,12 +31,16 @@ jobs: - name: Install Python tooling dependencies shell: bash - run: python3 -m pip install -r tools/requirements.txt + run: python3 -m pip install --require-hashes -r tools/requirements.txt - name: Verify core/go module versions are aligned shell: bash run: python3 tools/dev/check_module_versions.py + - name: Verify .bazelversion parity + shell: bash + run: python3 tools/dev/check_bazelversion_sync.py + - name: Verify schema files are in sync shell: bash run: python3 tools/core/schemas/sync_agentless_schema.py --check @@ -68,3 +72,197 @@ jobs: run: | echo "Release validation completed." echo "Before publishing, follow CONTRIBUTING.md release runbook steps." + + release-hermetic: + timeout-minutes: 45 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Run Bazel tests (hermetic lane) + timeout-minutes: 25 + shell: bash + run: | + ./bazelw test //tools/... \ + --spawn_strategy=sandboxed \ + --strategy=TestRunner=sandboxed \ + --incompatible_strict_action_env \ + --sandbox_default_allow_network=false \ + --modify_execution_info=TestRunner=+block-network \ + --test_env=TZ=UTC \ + --test_env=LANG=C \ + --test_env=LC_ALL=C \ + --enable_runfiles + + - name: Run Bazel tests (go companion hermetic lane) + timeout-minutes: 25 + shell: bash + run: | + cd modules/go && + ../../bazelw test //... \ + --override_module=datadog-rules-test-optimization=../.. \ + --spawn_strategy=sandboxed \ + --strategy=TestRunner=sandboxed \ + --incompatible_strict_action_env \ + --sandbox_default_allow_network=false \ + --modify_execution_info=TestRunner=+block-network \ + --test_env=TZ=UTC \ + --test_env=LANG=C \ + --test_env=LC_ALL=C \ + --enable_runfiles + + release-shell-lint: + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install shellcheck + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + + - name: Lint repository shell scripts + shell: bash + run: | + shellcheck --severity=error \ + bazelw \ + examples/single_service/runtests.sh \ + examples/multi_service/runtests.sh \ + tools/tests/integration/*.sh \ + tools/tests/python/run_python_tools_test.sh \ + tools/tests/python/run_bazelw_wrapper_test.sh + + - name: Lint uploader templates (bash parser) + shell: bash + run: python3 tools/dev/lint_uploader_templates.py --skip-powershell-parse + + release-powershell-lint: + timeout-minutes: 20 + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install PSScriptAnalyzer + shell: pwsh + run: | + if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) { + if (-not (Get-PackageProvider -ListAvailable -Name NuGet -ErrorAction SilentlyContinue)) { + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force + } + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted + Install-Module -Name PSScriptAnalyzer -Scope CurrentUser -Force -AllowClobber + } + + - name: Lint integration PowerShell scripts + shell: pwsh + run: | + $results = Invoke-ScriptAnalyzer -Path "tools/tests/integration/*.ps1" -Severity Error + if ($results) { + $results | Format-Table -AutoSize + throw "PSScriptAnalyzer found lint errors." + } + + - name: Lint uploader templates (PowerShell parser) + shell: pwsh + run: python tools/dev/lint_uploader_templates.py --skip-shellcheck + + release-gofmt-check: + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Verify Go files are gofmt formatted + shell: bash + run: | + mapfile -t go_files < <(git ls-files '*.go') + if (( ${#go_files[@]} == 0 )); then + exit 0 + fi + unformatted="$(gofmt -l "${go_files[@]}")" + if [[ -n "${unformatted}" ]]; then + echo "error: gofmt found unformatted files:" + echo "${unformatted}" + exit 1 + fi + + release-docs-links: + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Check markdown links + uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 # v2.4.1 + with: + args: >- + --glob + --no-progress + --verbose + --exclude-mail + --accept 429 + --max-retries 2 + --retry-wait-time 2 + README.md + CONTRIBUTING.md + AGENTS.md + CHANGELOG.md + SECURITY.md + docs/**/*.md + examples/**/*.md + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + release-platform-smoke: + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Ensure jq is available (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + if (Get-Command jq -ErrorAction SilentlyContinue) { + jq --version + exit 0 + } + choco install jq --no-progress -y + jq --version + + - name: Run mock server integration tests (macOS) + if: runner.os == 'macOS' + shell: bash + run: ./tools/tests/integration/run_mock_server_tests.sh + + - name: Run mock server integration tests (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: ./tools/tests/integration/run_mock_server_tests.ps1 diff --git a/BUILD.bazel b/BUILD.bazel index 8a7a600b..74cd67dd 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -1,2 +1,5 @@ # Root package exists to expose repository-level utility files to tests. -exports_files(["bazelw"]) +exports_files([ + ".bazelversion", + "bazelw", +]) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ded16cdb..b2270732 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ - Python tooling tests: - `./bazelw test //tools/tests/python:python_tools_test` - Optional Python tooling dependencies (for local script execution): - - `python3 -m pip install -r tools/requirements.txt` + - `python3 -m pip install --require-hashes -r tools/requirements.txt` - Local lint prerequisites (match CI tooling): - `shellcheck` (shell lint lane) - `buildifier` (Starlark formatting lane) @@ -40,7 +40,7 @@ - Optional Python syntax smoke check when editing tooling: - `python3 -m py_compile tools/core/validate_payload_schema.py tools/core/schemas/sync_agentless_schema.py tools/tests/integration/mock_dd_server.py` - Integration harness: - - Prerequisites: `jq` (Linux/macOS) and Git Bash available in PATH on Windows. + - Prerequisites: `jq` (Linux/macOS). Windows harness is PowerShell-only. - Linux/macOS: `tools/tests/integration/run_mock_server_tests.sh` - Windows primary entrypoint: `tools/tests/integration/run_mock_server_tests.ps1` - Windows convenience wrapper: `tools/tests/integration/run_mock_server_tests.cmd` @@ -71,6 +71,7 @@ - scope policy: Linux-only by design today; non-Linux hermetic expansion is tracked separately to keep CI runtime bounded - Utility/lint lanes: - module version alignment check (`tools/dev/check_module_versions.py`) + - `.bazelversion` parity check (`tools/dev/check_bazelversion_sync.py`) - shell scripts, PowerShell, Buildifier, gofmt, schema sync checks, fixture JSON checks, and Python tooling tests - Workflow dependency pinning: - Keep GitHub Actions pinned by commit SHA and preserve the `# vX.Y.Z` comment. diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 1ff0cf32..7ccfd70e 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -183,7 +183,7 @@ }, "//tools/tests:example_stub_repo.bzl%example_stub_repo_extension": { "general": { - "bzlTransitiveDigest": "3VyTSl4YGCt3++pBN/edaYl3iIwdbuGQDsPxwqtmNkA=", + "bzlTransitiveDigest": "CupZAjLL9G93Tem/3CDHO/m75vLv420DmywdYOhZAHY=", "usagesDigest": "XsrGIwV6f2LnU06owJNbMouQ+/gMiw6hxMQqdC32pHE=", "recordedFileInputs": {}, "recordedDirentsInputs": {}, diff --git a/README.md b/README.md index feb06702..5c4c46fa 100644 --- a/README.md +++ b/README.md @@ -211,11 +211,11 @@ For a generic wrapper pattern, see [Other languages (without companion macro)](# - **Bazel 5.0+ minimum capability** - Earliest Bazel line with required `TEST_UNDECLARED_OUTPUTS_DIR` payload support - **Tracer/runtime with DD Test Optimization file-mode support** - Must honor `DD_TEST_OPTIMIZATION_MANIFEST_FILE` and `DD_TEST_OPTIMIZATION_PAYLOADS_IN_FILES` - **rules_go v0.51.0+** (for Go importpath inference) - This repository reads `GoInfo`/`GoArchive` providers when selecting per-module payloads -- **DD_SITE format** - Accepts bare host, app/api-prefixed host, or full URL; normalized to `https://api.` +- **DD_SITE format** - Accepts bare host, app/api-prefixed host, or full URL; leading/trailing ASCII whitespace is trimmed, then normalized to `https://api.` - **Uploader tooling (per platform)** - Required for `bazel run //:dd_upload_payloads` - **Linux**: `bash`, `curl`, `find`, `stat` (GNU), `awk`, and one of `md5sum` or `shasum` - **macOS**: `bash` (3.2+), `curl`, `find`, `stat` (BSD), `awk`, and one of `md5` or `shasum` - - **Windows**: `powershell.exe` (Windows PowerShell 5.1+ or PowerShell 7+); the uploader uses .NET `HttpClient` + - **Windows**: `powershell.exe` (Windows PowerShell 5.1+ or PowerShell 7+); the uploader uses .NET `HttpClient` and is intentionally PowerShell-only (no Git Bash dependency) Optional tooling: - **jq** (Linux/macOS) - Used to enrich test payloads with `context.json`. If missing, uploads proceed without enrichment. @@ -535,6 +535,7 @@ Fast checks before diving deep: - Full troubleshooting playbook: [`docs/Troubleshooting.md`](docs/Troubleshooting.md) - Configuration and fetch behavior reference: [`docs/Configuration_Reference.md`](docs/Configuration_Reference.md) - Uploader runtime reference: [`docs/Uploader_Reference.md`](docs/Uploader_Reference.md) +- External-link provenance note: repository behavior is source-of-truth in this repo's code/tests; external docs are informative and may lag temporarily. ## Tips diff --git a/bazelw b/bazelw index 744749be..b807a32f 100755 --- a/bazelw +++ b/bazelw @@ -7,6 +7,37 @@ set -euo pipefail now_ts="$(date +%s)" ttl_seconds="${FETCH_SALT_TTL:-0}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +bazelversion_file="${script_dir}/.bazelversion" +wanted_version="" +if [[ -f "${bazelversion_file}" ]]; then + wanted_version="$(tr -d '[:space:]' < "${bazelversion_file}")" +fi +if [[ -z "${wanted_version}" ]]; then + echo "error: unable to read expected Bazel version from ${bazelversion_file}" >&2 + exit 127 +fi + +extract_semver() { + local raw="${1:-}" + if [[ "${raw}" =~ ([0-9]+\.[0-9]+\.[0-9]+) ]]; then + printf '%s' "${BASH_REMATCH[1]}" + return 0 + fi + return 1 +} + +detect_system_bazel_version() { + local raw="" + local parsed="" + raw="$(bazel --version 2>/dev/null || true)" + parsed="$(extract_semver "${raw}" || true)" + if [[ -z "${parsed}" ]]; then + raw="$(bazel version 2>/dev/null | awk -F': ' '/Build label:/ { print $2; exit }')" + parsed="$(extract_semver "${raw}" || true)" + fi + printf '%s' "${parsed}" +} if [[ "${ttl_seconds}" =~ ^[0-9]+$ ]] && [[ "${ttl_seconds}" -gt 0 ]]; then salt=$(( now_ts / ttl_seconds )) @@ -16,8 +47,24 @@ fi if command -v bazelisk >/dev/null 2>&1; then bazel_cmd="bazelisk" +elif command -v bazel >/dev/null 2>&1; then + system_bazel_version="$(detect_system_bazel_version)" + if [[ "${BAZELW_ALLOW_SYSTEM_BAZEL:-0}" == "1" ]]; then + echo "warning: bazelisk not found; using system bazel due to BAZELW_ALLOW_SYSTEM_BAZEL=1" >&2 + bazel_cmd="bazel" + elif [[ -z "${system_bazel_version}" ]]; then + echo "error: bazelisk is required to enforce .bazelversion (unable to detect system bazel version)." >&2 + exit 127 + elif [[ "$system_bazel_version" == "$wanted_version" ]]; then + echo "warning: bazelisk not found; using system bazel version $system_bazel_version (matches .bazelversion)" >&2 + bazel_cmd="bazel" + else + echo "error: bazelisk is required to enforce .bazelversion (wanted $wanted_version, found system bazel $system_bazel_version)." >&2 + exit 127 + fi else - bazel_cmd="bazel" + echo "error: bazelisk is required to enforce .bazelversion (set BAZELW_ALLOW_SYSTEM_BAZEL=1 to override)." >&2 + exit 127 fi sanitize_repository_url() { diff --git a/docs/Configuration_Reference.md b/docs/Configuration_Reference.md index ba784409..e5a82a7a 100644 --- a/docs/Configuration_Reference.md +++ b/docs/Configuration_Reference.md @@ -99,7 +99,7 @@ changes can invalidate fetch cache entries as expected. | Variable | Required | Purpose | |----------|----------|---------| | `DD_API_KEY` | Yes | Datadog API key for metadata fetches | -| `DD_SITE` | No | Site domain (`datadoghq.com`, `datadoghq.eu`, etc.). Values like `app.` normalize to `api.` | +| `DD_SITE` | No | Site domain (`datadoghq.com`, `datadoghq.eu`, etc.). Leading/trailing ASCII whitespace is trimmed; values like `app.` normalize to `api.` | | `DD_TEST_OPTIMIZATION_API_BASE` | No | Override sync API base URL (test/dev and mock-server scenarios) | | `FETCH_SALT` | No | Manual refetch trigger (example: `--repo_env=FETCH_SALT=`) | | `GO_MODULE_PATH` | No | Explicit Go module path override used when emitting `export.bzl` | @@ -148,6 +148,11 @@ Auto-detection currently maps CI metadata from: Provider-name note: AWS CodeBuild is emitted as `awscodebuild` in sync metadata (`ci.provider.name`). +Provider precedence note: +- explicit `DD_GIT_*` overrides win first; +- CI-provider environment detection is second; +- git CLI fallback (`git rev-parse`, `git log`, etc.) is last. + Additional mapped metadata inputs include: - `APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH` @@ -176,11 +181,15 @@ The uploader rule reads these variables at `bazel run` time: | `DD_TEST_OPTIMIZATION_CODEOWNERS_FILE` | Explicit CODEOWNERS path for enrichment | | `TESTLOGS_DIR` | Explicit `bazel-testlogs` path for non-standard layouts | +Numeric precision caveat: +- Keep high-cardinality IDs (for example CI job IDs) as strings when possible. + Some JSON tooling and runtimes may lose precision for integers above `2^53 - 1`. + ## Integration harness environment variables (maintainer workflows) These variables are used by repository integration harness scripts (not by the sync repository rule or uploader runtime paths above): -| Variable | Purpose | -|----------|---------| -| `DD_TEST_OPTIMIZATION_GIT_BASH` | Optional absolute path override for Git Bash in `tools/tests/integration/run_mock_server_tests.ps1` when auto-discovery is not suitable | +- Linux/macOS harness supports existing shell env overrides documented in + `tools/tests/integration/run_mock_server_tests.sh`. +- Windows harness is PowerShell-only; no Git Bash path override variable is used. diff --git a/docs/Initial_documentation.md b/docs/Initial_documentation.md index 1437bc48..d1bac2b1 100644 --- a/docs/Initial_documentation.md +++ b/docs/Initial_documentation.md @@ -16,7 +16,7 @@ The steps are: - `@//:module_` (per‑module bundle: `cache/http/settings.json` + that module’s known/test‑management files) The sync also emits an `export.bzl` helper describing available module labels, the resolved `manifest_path`, and detected runtime/module hints for consumers. Per‑module targets expose canonical runfile names rooted at the manifest directory (`/...`, default `.testoptimization/...`) regardless of where split files are stored physically. Notes: - - `DD_SITE` accepts bare host, app/api-prefixed host, or full URL; it is normalized to `https://api.`. + - `DD_SITE` accepts bare host, app/api-prefixed host, or full URL; ASCII whitespace is trimmed and value is normalized to `https://api.`. - Module labels are computed from the union of known-tests and test-management modules to avoid cross-feature collisions. Reference implementation: [https://github.com/DataDog/rules\_test\_optimization](https://github.com/DataDog/rules_test_optimization) diff --git a/docs/Installation_Reference.md b/docs/Installation_Reference.md index 162f5119..5f1ea50e 100644 --- a/docs/Installation_Reference.md +++ b/docs/Installation_Reference.md @@ -31,6 +31,9 @@ bazel_dep(name = "rules_go", version = "0.59.0") ``` Use the same full commit SHA (40 chars) for core and companion modules. +For mirrored/archive installs, also pin and verify archive `sha256` values (see +"Archive mirror installation" below) so the fetched source is integrity-checked +in CI and local builds. ### Option B: local development overrides diff --git a/docs/Maintainers.md b/docs/Maintainers.md index c0bfcd73..39cfab29 100644 --- a/docs/Maintainers.md +++ b/docs/Maintainers.md @@ -143,8 +143,8 @@ tools\tests\integration\run_mock_server_tests.cmd Notes: -- The PowerShell entrypoint reuses the Bash harness for parity and prefers Git - for Windows `bash.exe` (or `DD_TEST_OPTIMIZATION_GIT_BASH` when set). +- The PowerShell entrypoint is native and self-contained (no Git Bash + dependency). Linux/macOS keep the Bash harness. - Test-only endpoint overrides: - `DD_TEST_OPTIMIZATION_API_BASE` (sync) - `DD_TEST_OPTIMIZATION_INTAKE_BASE` (uploader, agentless path) diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md index 8c41b783..fb06ebb6 100644 --- a/docs/Troubleshooting.md +++ b/docs/Troubleshooting.md @@ -182,9 +182,20 @@ full bundle. 2. **Check paths use forward slashes** in Starlark/Bazel contexts (backslashes are auto-converted). -3. **Git Bash path conversion**: when running the integration harness from - PowerShell, ensure `cygpath` is available (Git for Windows). The harness - falls back to raw paths, but mixed-path conversion improves consistency. +3. **Use native PowerShell harness on Windows**: + - `.\tools\tests\integration\run_mock_server_tests.ps1` + - Git Bash is not required for Windows integration runs. + +## Safe command patterns + +- Prefer explicit env assignment over shell interpolation in troubleshooting + commands: + - Unix: `DD_API_KEY="$DD_API_KEY" DD_SITE="$DD_SITE" bazel run //:dd_upload_payloads` + - PowerShell: set `$env:DD_API_KEY` and `$env:DD_SITE` first, then run `bazel run //:dd_upload_payloads` +- Quote paths containing spaces and avoid `eval`-style wrappers. +- For refetch debugging, use: + - `bazel sync --only= --repo_env=FETCH_SALT=` + - if required by workspace mode: `bazel sync --enable_workspace --only= --repo_env=FETCH_SALT=` ## Getting help diff --git a/docs/Uploader_Reference.md b/docs/Uploader_Reference.md index d719ecfd..c30afaa6 100644 --- a/docs/Uploader_Reference.md +++ b/docs/Uploader_Reference.md @@ -176,8 +176,9 @@ payload discovery/quiescence before proceeding. - Tests: `https://citestcycle-intake./api/v2/citestcycle` - Coverage: `https://citestcov-intake./api/v2/citestcov` - Requires `DD_API_KEY` - - `DD_SITE` is validated as a hostname (with compatibility normalization for - `app.`/`api.` prefixes and URL-shaped inputs); credentials/ports are rejected + - `DD_SITE` is validated as a hostname (ASCII-whitespace is trimmed first), + with compatibility normalization for `app.`/`api.` prefixes and URL-shaped + inputs; credentials/ports are rejected - Test/dev override: set `DD_TEST_OPTIMIZATION_INTAKE_BASE` to use a custom base URL (agentless only) - EVP proxy (when `DD_TRACE_AGENT_URL` set): @@ -194,7 +195,7 @@ payload discovery/quiescence before proceeding. - Both transient errors (connection issues) and HTTP errors (4xx/5xx) trigger retries - Behavior is consistent across Linux/macOS (bash/curl) and Windows - (PowerShell) + (PowerShell-only runtime path; no Git Bash requirement) ## Metadata enrichment (`context.json`) diff --git a/docs/audit/findings_validation_2026_02.md b/docs/audit/findings_validation_2026_02.md deleted file mode 100644 index 5f26ee36..00000000 --- a/docs/audit/findings_validation_2026_02.md +++ /dev/null @@ -1,75 +0,0 @@ -# Findings Validation And Remediation Tracker (2026-02) - -This tracker is the execution ledger for findings in: -- `/Users/tony.redondo/Downloads/findings01.md` -- `/Users/tony.redondo/Downloads/findings02.md` - -Disposition meanings: -- `fix`: code/test/workflow/doc change required in this branch. -- `mitigate-doc`: keep behavior but add/strengthen policy and rationale documentation. -- `close-no-change`: finding is false/unverifiable/already addressed; keep evidence in PR. - -## Findings 01 - -| ID | Validation | Disposition | Done | Evidence | -| --- | --- | --- | --- | --- | -| 1.1 | true | fix | [x] | `tools/core/test_optimization_sync_env.bzl`, `tools/tests/core/test_sync_utils.bzl` | -| 2.1 | true | fix | [x] | `modules/go/topt_go_test.bzl`, `modules/go/tests/test_macro.bzl` | -| 2.2 | true | fix | [x] | `tools/core/test_optimization_sync.bzl` | -| 2.3 | partially_true | fix | [x] | `tools/core/test_optimization_sync.bzl`, command/path guard tests | -| 2.4 | true | close-no-change | [x] | Bazel predeclared `platform_common` documented in PR rationale | -| 2.5 | partially_true | fix | [x] | New env-helper coverage in `tools/tests/core/test_sync_utils.bzl` | -| 3.1 | true | fix | [x] | `_MAX_REF_STRIP_ITERATIONS` in `test_optimization_sync_env.bzl` | -| 3.2 | true | fix | [x] | `_split_json_payload_by_module` in `test_optimization_sync.bzl` | -| 3.3 | true | fix | [x] | Fail-prefix policy + targeted sync prefix cleanup (`test_optimization_sync`) | -| 3.4 | true | mitigate-doc | [x] | Decomposition roadmap in `docs/Maintainers.md` | -| 3.5 | true | mitigate-doc | [x] | Bazel baseline/workspace-compat clarified in `README.md` | -| 3.6 | true | fix | [x] | `tools/dev/check_module_versions.py` validates `RULES_VERSION` alignment | -| 3.7 | true | fix | [x] | Step-level timeouts in `.github/workflows/ci.yml` | -| 3.9 | partially_true | fix | [x] | `DD_TEST_OPTIMIZATION_MAX_WAIT_SEC=0` scenario in integration harness | -| 3.10 | true | fix | [x] | Mock body-limit configurability in `tools/tests/integration/mock_dd_server.py` | -| 3.11 | true | fix | [x] | Pre-check readable input files in `validate_payload_schema.py` | -| 3.12 | true | fix | [x] | Expanded tools coverage target list in CI workflow | -| 3.13 | true | mitigate-doc | [x] | Schema source-of-truth workflow in `CONTRIBUTING.md` + `docs/Maintainers.md` | -| 3.14 | true | fix | [x] | `CHANGELOG.md` + `SECURITY.md` added to docs-links workflow | -| 3.15 | false | close-no-change | [x] | Existing Windows Git Bash prerequisite remains documented/guarded | -| 3.16 | partially_true | fix | [x] | Control-character path rejection + command builder failure test | -| 3.17 | true | mitigate-doc | [x] | Linux-only hermetic scope policy documented in maintainer/contrib docs | -| 3.18 | true | fix | [x] | Added `1.0.0` section to `CHANGELOG.md` | -| 4.1 | unverifiable | close-no-change | [x] | Historical RFC date left unchanged (no authoritative repo evidence) | -| 4.2 | true | fix | [x] | Targeted cleanup while touching affected paths | -| 4.3 | true | fix | [x] | `RUNTESTS_DRY_RUN` documented in `examples/README.md` | -| 4.4 | partially_true | fix | [x] | Filter-prefix purpose clarified in `docs/Uploader_Reference.md` | -| 4.5 | true | fix | [x] | Determinism + known-vector hash assertions in sync utility tests | -| 4.6 | true | fix | [x] | Strengthened macro/stub assertions and new env-none regression test | -| 4.7 | partially_true | close-no-change | [x] | Intentional cache-busting retained; policy documented | -| 4.9 | partially_true | fix | [x] | Explicit `MAX_WAIT_SEC` behavior section in uploader docs | -| 4.10 | true | fix | [x] | Codefresh sha/repository mapping in `test_optimization_sync_env.bzl` + tests | -| 4.11 | true | fix | [x] | Added empty/minimal environment regression test | -| 4.12 | true | fix | [x] | Shared sanitization constant reused via `common_utils` | -| 4.14 | false | close-no-change | [x] | `FETCH_SALT_TTL` documentation already present; retained | -| 4.15 | true | mitigate-doc | [x] | ShellCheck severity policy rationale documented in CI workflow | - -## Findings 02 - -| ID | Validation | Disposition | Done | Evidence | -| --- | --- | --- | --- | --- | -| F-01 | true | fix | [x] | Buildifier checksum verification in `.github/workflows/ci.yml` | -| F-02 | true | fix | [x] | Strict DD_SITE hostname validation in sync + uploader templates | -| F-03 | true | fix | [x] | Event-aware Buildifier diff range logic in CI workflow | -| F-04 | true | fix | [x] | Expanded Python tools coverage target list | -| F-05 | true | fix | [x] | Unsupported schema keywords now error by default (+ warn-mode override) | -| F-06 | true | fix | [x] | `actions/setup-python` with pinned version in CI/release jobs | -| F-07 | true | fix | [x] | Release workflow always runs full validation path | -| F-08 | true | fix | [x] | Explicit Windows `jq` setup/check step in CI | -| F-09 | true | mitigate-doc | [x] | README clarifies 8.5.1 baseline vs 8.4.1 workspace-compat lane | -| F-10 | true | fix | [x] | Deterministic Python dependency pin (`PyYAML==6.0.3`) | -| F-11 | true | fix | [x] | Docs link-check scope now includes `CHANGELOG.md` + `SECURITY.md` | -| F-12 | true | fix | [x] | Parser parity tool now emits actionable install/remediation hints | -| F-13 | true | mitigate-doc | [x] | Decomposition roadmap added to maintainer docs | -| F-14 | true | fix | [x] | Local lint prerequisites documented in `CONTRIBUTING.md` | - -## Close-No-Change Acceptance Criteria - -- Include code/doc evidence links in PR description for `2.4`, `3.15`, `4.1`, `4.7`, and `4.14`. -- For `4.1`, keep as unresolved historical metadata unless maintainer provides authoritative date source. diff --git a/examples/README.md b/examples/README.md index b30b56fb..59a301b8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -118,10 +118,13 @@ Notes: - The sequence above intentionally preserves the test exit code. - Uploader failures are still reported in uploader logs/output; monitor those in CI. - Example `runtests.sh` scripts default `DD_SITE` to `datadoghq.com` when not set. +- Windows-friendly wrappers are provided as `examples/*/runtests.ps1` and use + native PowerShell + Bazel (no Git Bash dependency). Dry-run mode for CI/debugging: - Set `RUNTESTS_DRY_RUN=1` when invoking `examples/*/runtests.sh` to print the commands that would run without executing Bazel test/upload operations. +- PowerShell wrappers honor the same `RUNTESTS_DRY_RUN=1` environment variable. ## Multi-service (aggregator) diff --git a/examples/common/runtests_common.ps1 b/examples/common/runtests_common.ps1 new file mode 100644 index 00000000..3ce2ef39 --- /dev/null +++ b/examples/common/runtests_common.ps1 @@ -0,0 +1,62 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# Handle Invoke-RunCmd behavior. +function Invoke-RunCmd { + param( + [Parameter(Mandatory = $true)] + [string]$Command, + [Parameter()] + [string[]]$Args = @() + ) + + if ($env:RUNTESTS_DRY_RUN -eq "1") { + Write-Output ("[dry-run] {0} {1}" -f $Command, ($Args -join " ")) + return 0 + } + + & $Command @Args + return $LASTEXITCODE +} + +# Handle Get-BazelCommand behavior. +function Get-BazelCommand { + $bazel = Get-Command bazel -ErrorAction SilentlyContinue + if (-not $bazel) { + throw "bazel not found in PATH. On Windows, runtests.ps1 requires native Bazel/Bazelisk." + } + return $bazel.Source +} + +# Handle Invoke-ExampleRunTests behavior. +function Invoke-ExampleRunTests { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptDir + ) + + Push-Location $ScriptDir + try { + $bazelCmd = Get-BazelCommand + $testStatus = 0 + + Write-Output "--- non-hermetic run" + $rc = Invoke-RunCmd -Command $bazelCmd -Args @("test", "//src/go-project/...", "--test_output=streamed", "--test_arg=-test.v", "--sandbox_debug") + if ($rc -ne 0) { $testStatus = $rc } + + Write-Output "--- hermetic run" + $rc = Invoke-RunCmd -Command $bazelCmd -Args @("test", "//src/go-project/...", "--test_output=streamed", "--test_arg=-test.v", "--sandbox_debug", "--config=hermetic") + if ($rc -ne 0) { $testStatus = $rc } + + Write-Output "--- uploading payloads" + if (-not $env:DD_SITE) { $env:DD_SITE = "datadoghq.com" } + $uploadRc = Invoke-RunCmd -Command $bazelCmd -Args @("run", "//:dd_upload_payloads") + if ($uploadRc -ne 0) { + Write-Warning "payload upload failed; preserving test exit code ($testStatus)." + } + + exit $testStatus + } finally { + Pop-Location + } +} diff --git a/examples/common/runtests_common.sh b/examples/common/runtests_common.sh new file mode 100644 index 00000000..dbe84cff --- /dev/null +++ b/examples/common/runtests_common.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Handle run example runtests behavior. +run_example_runtests() { + local script_dir="$1" + local bazelw + local test_status=0 + bazelw="${script_dir}/../../bazelw" + + cd "$script_dir" + + # Handle run cmd behavior. + run_cmd() { + if [[ "${RUNTESTS_DRY_RUN:-0}" == "1" ]]; then + echo "[dry-run] $*" + return 0 + fi + "$@" + } + + echo "--- non-hermetic run" + run_cmd "${bazelw}" test //src/go-project/... --test_output=streamed --test_arg=-test.v --sandbox_debug || test_status=$? + + echo "--- hermetic run" + run_cmd "${bazelw}" test //src/go-project/... --test_output=streamed --test_arg=-test.v --sandbox_debug --config=hermetic || test_status=$? + + echo "--- uploading payloads" + # Requires DD_API_KEY and DD_SITE environment variables. + if ! DD_API_KEY="${DD_API_KEY:-}" DD_SITE="${DD_SITE:-datadoghq.com}" run_cmd "${bazelw}" run //:dd_upload_payloads; then + echo "warning: payload upload failed; preserving test exit code (${test_status})." >&2 + fi + + # Preserve the test exit code even if uploads fail. + return "$test_status" +} diff --git a/examples/multi_service/runtests.ps1 b/examples/multi_service/runtests.ps1 new file mode 100644 index 00000000..685b3043 --- /dev/null +++ b/examples/multi_service/runtests.ps1 @@ -0,0 +1,7 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path $scriptDir "..\common\runtests_common.ps1") + +Invoke-ExampleRunTests -ScriptDir $scriptDir diff --git a/examples/multi_service/runtests.sh b/examples/multi_service/runtests.sh index dd1075db..a1795b98 100755 --- a/examples/multi_service/runtests.sh +++ b/examples/multi_service/runtests.sh @@ -2,30 +2,5 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR" -BAZELW="${SCRIPT_DIR}/../../bazelw" - -run_cmd() { - if [[ "${RUNTESTS_DRY_RUN:-0}" == "1" ]]; then - echo "[dry-run] $*" - return 0 - fi - "$@" -} - -test_status=0 - -echo "--- non-hermetic run" -run_cmd "${BAZELW}" test //src/go-project/... --test_output=streamed --test_arg=-test.v --sandbox_debug || test_status=$? - -echo "--- hermetic run" -run_cmd "${BAZELW}" test //src/go-project/... --test_output=streamed --test_arg=-test.v --sandbox_debug --config=hermetic || test_status=$? - -echo "--- uploading payloads" -# Requires DD_API_KEY and DD_SITE environment variables. -if ! DD_API_KEY="${DD_API_KEY:-}" DD_SITE="${DD_SITE:-datadoghq.com}" run_cmd "${BAZELW}" run //:dd_upload_payloads; then - echo "warning: payload upload failed; preserving test exit code (${test_status})." >&2 -fi - -# Preserve the test exit code even if uploads fail. -exit $test_status +source "${SCRIPT_DIR}/../common/runtests_common.sh" +run_example_runtests "${SCRIPT_DIR}" diff --git a/examples/single_service/runtests.ps1 b/examples/single_service/runtests.ps1 new file mode 100644 index 00000000..685b3043 --- /dev/null +++ b/examples/single_service/runtests.ps1 @@ -0,0 +1,7 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +. (Join-Path $scriptDir "..\common\runtests_common.ps1") + +Invoke-ExampleRunTests -ScriptDir $scriptDir diff --git a/examples/single_service/runtests.sh b/examples/single_service/runtests.sh index 762fce11..a1795b98 100755 --- a/examples/single_service/runtests.sh +++ b/examples/single_service/runtests.sh @@ -2,30 +2,5 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR" -BAZELW="${SCRIPT_DIR}/../../bazelw" - -run_cmd() { - if [[ "${RUNTESTS_DRY_RUN:-0}" == "1" ]]; then - echo "[dry-run] $*" - return 0 - fi - "$@" -} - -test_status=0 - -echo "--- non-hermetic run" -run_cmd "${BAZELW}" test //src/go-project/... --test_output=streamed --test_arg=-test.v --sandbox_debug || test_status=$? - -echo "--- hermetic run" -run_cmd "${BAZELW}" test //src/go-project/... --test_output=streamed --test_arg=-test.v --sandbox_debug --config=hermetic || test_status=$? - -echo "--- uploading payloads" -# Requires DD_API_KEY and DD_SITE environment variables -if ! DD_API_KEY="${DD_API_KEY:-}" DD_SITE="${DD_SITE:-datadoghq.com}" run_cmd "${BAZELW}" run //:dd_upload_payloads; then - echo "warning: payload upload failed; preserving test exit code (${test_status})." >&2 -fi - -# Preserve the test exit code even if uploads fail. -exit $test_status +source "${SCRIPT_DIR}/../common/runtests_common.sh" +run_example_runtests "${SCRIPT_DIR}" diff --git a/modules/go/tests/BUILD.bazel b/modules/go/tests/BUILD.bazel index 94f97a32..4615197a 100644 --- a/modules/go/tests/BUILD.bazel +++ b/modules/go/tests/BUILD.bazel @@ -40,6 +40,8 @@ load( "selector_deps_precedence_test", "selector_explicit_precedence_target", "selector_explicit_precedence_test", + "selector_empty_importpath_fallback_target", + "selector_empty_importpath_fallback_test", "selector_fallback_target", "selector_fallback_test", "selector_include_disabled_target", @@ -150,6 +152,16 @@ selector_no_match_fallback_test( target_under_test = ":selector_no_match_fallback_target", ) +selector_empty_importpath_fallback_target( + name = "selector_empty_importpath_fallback_target", + tags = ["manual"], +) + +selector_empty_importpath_fallback_test( + name = "selector_empty_importpath_fallback_test", + target_under_test = ":selector_empty_importpath_fallback_target", +) + selector_include_disabled_target( name = "selector_include_disabled_target", tags = ["manual"], @@ -298,6 +310,7 @@ test_suite( ":selector_deps_precedence_test", ":selector_fallback_test", ":selector_no_match_fallback_test", + ":selector_empty_importpath_fallback_test", ":selector_include_disabled_test", ":selector_override_test", ], diff --git a/modules/go/tests/test_payloads_selector.bzl b/modules/go/tests/test_payloads_selector.bzl index b71d6ebe..4dd8b2a7 100644 --- a/modules/go/tests/test_payloads_selector.bzl +++ b/modules/go/tests/test_payloads_selector.bzl @@ -28,6 +28,7 @@ _payload_marker = rule( ) def _embed_source_impl(_ctx): + """Implement embed source impl behavior.""" return [] _embed_source = rule( @@ -144,6 +145,19 @@ def selector_no_match_fallback_target(name, tags = None): tags = tags, ) +def selector_empty_importpath_fallback_target(name, tags = None): + """Selector falls back to full_files when all importpath sources are empty.""" + topt_go_payloads_selector( + name = name, + embeds = [], + explicit_importpath = "", + fallback_importpath = "", + full_files = ":full_payload", + module_groups = _COMMON_MODULE_GROUPS, + include_per_module = True, + tags = tags, + ) + def selector_include_disabled_target(name, tags = None): """Selector keeps full_files when include_per_module is disabled.""" topt_go_payloads_selector( @@ -172,12 +186,14 @@ def selector_override_target(name, tags = None): ) def _has_fragment(items, fragment): + """Implement has fragment behavior.""" for item in items: if fragment in item: return True return False def _assert_selected(env, target, expected_fragment): + """Implement assert selected behavior.""" files = [f.basename for f in target[DefaultInfo].files.to_list()] asserts.equals(env, 1, len(files)) asserts.true( @@ -187,42 +203,56 @@ def _assert_selected(env, target, expected_fragment): ) def _selector_explicit_precedence_test_impl(ctx): + """Implement selector explicit precedence test impl behavior.""" env = analysistest.begin(ctx) target = analysistest.target_under_test(env) _assert_selected(env, target, "module_example_com_explicit_pkg") return analysistest.end(env) def _selector_embed_precedence_test_impl(ctx): + """Implement selector embed precedence test impl behavior.""" env = analysistest.begin(ctx) target = analysistest.target_under_test(env) _assert_selected(env, target, "module_example_com_embed_pkg") return analysistest.end(env) def _selector_deps_precedence_test_impl(ctx): + """Implement selector deps precedence test impl behavior.""" env = analysistest.begin(ctx) target = analysistest.target_under_test(env) _assert_selected(env, target, "module_example_com_deps_pkg") return analysistest.end(env) def _selector_fallback_test_impl(ctx): + """Implement selector fallback test impl behavior.""" env = analysistest.begin(ctx) target = analysistest.target_under_test(env) _assert_selected(env, target, "module_example_com_fallback_pkg") return analysistest.end(env) def _selector_no_match_fallback_test_impl(ctx): + """Implement selector no match fallback test impl behavior.""" + env = analysistest.begin(ctx) + target = analysistest.target_under_test(env) + _assert_selected(env, target, "full_payload") + return analysistest.end(env) + +def _selector_empty_importpath_fallback_test_impl(ctx): + """Implement selector empty importpath fallback test impl behavior.""" env = analysistest.begin(ctx) target = analysistest.target_under_test(env) _assert_selected(env, target, "full_payload") return analysistest.end(env) def _selector_include_disabled_test_impl(ctx): + """Implement selector include disabled test impl behavior.""" env = analysistest.begin(ctx) target = analysistest.target_under_test(env) _assert_selected(env, target, "full_payload") return analysistest.end(env) def _selector_override_test_impl(ctx): + """Implement selector override test impl behavior.""" env = analysistest.begin(ctx) target = analysistest.target_under_test(env) _assert_selected(env, target, "module_custom_override") @@ -243,6 +273,9 @@ selector_fallback_test = analysistest.make( selector_no_match_fallback_test = analysistest.make( _selector_no_match_fallback_test_impl, ) +selector_empty_importpath_fallback_test = analysistest.make( + _selector_empty_importpath_fallback_test_impl, +) selector_include_disabled_test = analysistest.make( _selector_include_disabled_test_impl, ) diff --git a/modules/go/topt_go_test.bzl b/modules/go/topt_go_test.bzl index 7034111c..a6e2d503 100644 --- a/modules/go/topt_go_test.bzl +++ b/modules/go/topt_go_test.bzl @@ -56,6 +56,7 @@ _service_mapping_entries = service_mapping_entries _normalize_user_data = normalize_user_data def _resolve_topt_service_key(service_entries, topt_service): + """Implement resolve topt service key behavior.""" return resolve_topt_service_key(service_entries, topt_service, macro_name = "dd_topt_go_test") # Public aliases for unit tests. @@ -64,6 +65,7 @@ resolve_topt_service_key_for_tests = _resolve_topt_service_key normalize_user_data_for_tests = _normalize_user_data def _build_module_labels(sync_repo_name, labels): + """Implement build module labels behavior.""" if labels == None: return [] if not _is_list(labels): @@ -166,7 +168,9 @@ def dd_topt_go_test( include_per_module_files = False # Resolve sync repo name from selected service - sync_repo_name = _svc.get("repo_name") or "test_optimization_data" + sync_repo_name = _svc.get("repo_name") + if not sync_repo_name: + fail("dd_topt_go_test: selected topt_data entry is missing required 'repo_name'") # Decide whether to include per-module files: # - When inferring (explicit importpath or embed provided), always attempt per-module selection diff --git a/tools/core/BUILD.bazel b/tools/core/BUILD.bazel index 11c0c098..b21c664a 100644 --- a/tools/core/BUILD.bazel +++ b/tools/core/BUILD.bazel @@ -2,11 +2,15 @@ exports_files([ "common_utils.bzl", "schemas/agentless-schema.json", "schemas/agentless-schema.yaml", + "schemas/check_schema_parser_parity.py", "schemas/sync_agentless_schema.py", "test_optimization_sync.bzl", "test_optimization_multi_sync.bzl", "test_optimization_uploader.bzl", "topt_macro_utils.bzl", "topt_selection_utils.bzl", + "uploader_bash_runtime.sh.tpl", + "uploader_powershell_runtime.ps1.tpl", + "uploader_batch_runtime.bat.tpl", "validate_payload_schema.py", ], visibility = ["//visibility:public"]) diff --git a/tools/core/common_utils.bzl b/tools/core/common_utils.bzl index c042fb01..3fd67fd0 100644 --- a/tools/core/common_utils.bzl +++ b/tools/core/common_utils.bzl @@ -284,12 +284,17 @@ def dedup_keys(keys): c = base_counts.get(k, 0) + 1 base_counts[k] = c candidate = k if c == 1 else ("%s_%d" % (k, c)) - for _ in range(len(keys) + len(taken) + 2): + resolved = False + max_attempts = (len(keys) * 2) + len(taken) + 10 + for _ in range(max_attempts): if not taken.get(candidate): + resolved = True break c += 1 base_counts[k] = c candidate = "%s_%d" % (k, c) + if not resolved: + fail("dedup_keys: failed to allocate unique key for '%s' after %d attempts" % (k, max_attempts)) taken[candidate] = True out.append(candidate) return out diff --git a/tools/core/schemas/check_schema_parser_parity.py b/tools/core/schemas/check_schema_parser_parity.py index 5fbf6706..2fd9d69a 100644 --- a/tools/core/schemas/check_schema_parser_parity.py +++ b/tools/core/schemas/check_schema_parser_parity.py @@ -4,6 +4,11 @@ from __future__ import annotations import sys +from pathlib import Path + +_THIS_DIR = Path(__file__).resolve().parent +if str(_THIS_DIR) not in sys.path: + sys.path.insert(0, str(_THIS_DIR)) from sync_agentless_schema import ( # type: ignore _default_yaml_path, @@ -13,6 +18,7 @@ def main() -> int: + """Run CLI entrypoint logic and return process exit code.""" yaml_path = _default_yaml_path().resolve() try: pyyaml_data = _load_yaml_with_pyyaml(yaml_path) @@ -47,4 +53,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + sys.exit(main()) diff --git a/tools/core/schemas/sync_agentless_schema.py b/tools/core/schemas/sync_agentless_schema.py index a1ff078b..1abfb346 100644 --- a/tools/core/schemas/sync_agentless_schema.py +++ b/tools/core/schemas/sync_agentless_schema.py @@ -18,6 +18,7 @@ def _repo_root() -> Path: + """Internal helper for repo root behavior.""" here = Path(__file__).resolve().parent for candidate in [here] + list(here.parents): if (candidate / "MODULE.bazel").exists() or (candidate / ".git").exists(): @@ -26,14 +27,17 @@ def _repo_root() -> Path: def _default_yaml_path() -> Path: + """Internal helper for default yaml path behavior.""" return _repo_root() / "tools" / "core" / "schemas" / "agentless-schema.yaml" def _default_json_path() -> Path: + """Internal helper for default json path behavior.""" return _repo_root() / "tools" / "core" / "schemas" / "agentless-schema.json" def _load_yaml_with_pyyaml(path: Path) -> Any: + """Internal helper for load yaml with pyyaml behavior.""" try: import yaml # type: ignore except ImportError as exc: @@ -43,6 +47,7 @@ def _load_yaml_with_pyyaml(path: Path) -> Any: def _load_yaml_with_ruby(path: Path) -> Any: + """Internal helper for load yaml with ruby behavior.""" ruby = shutil.which("ruby") if not ruby: raise RuntimeError("Ruby is not available") @@ -69,6 +74,7 @@ def _load_yaml_with_ruby(path: Path) -> Any: def load_yaml(path: Path) -> Any: + """Implement load yaml behavior.""" pyyaml_error: Exception | None = None try: return _load_yaml_with_pyyaml(path) @@ -87,15 +93,18 @@ def load_yaml(path: Path) -> Any: def load_json(path: Path) -> Any: - with path.open("r", encoding="utf-8") as handle: + """Implement load json behavior.""" + with path.open("r", encoding="utf-8-sig") as handle: return json.load(handle) def render_json(data: Any) -> str: + """Implement render json behavior.""" return json.dumps(data, indent=2) + "\n" def parse_args() -> argparse.Namespace: + """Implement parse args behavior.""" parser = argparse.ArgumentParser( description="Sync tools/core/schemas/agentless-schema.json from YAML source." ) @@ -122,6 +131,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: + """Run CLI entrypoint logic and return process exit code.""" args = parse_args() yaml_path = args.yaml_path.resolve() json_path = args.json_path.resolve() diff --git a/tools/core/test_optimization_sync.bzl b/tools/core/test_optimization_sync.bzl index 040814f6..1dc55e9c 100644 --- a/tools/core/test_optimization_sync.bzl +++ b/tools/core/test_optimization_sync.bzl @@ -221,6 +221,10 @@ def _is_windows(ctx): _FINGERPRINT_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_:/.+@=#%~!$^*()[]{}<>?,;|\\\"'` " +def _powershell_single_quote_literal(value): + """Escape value for safe use inside a single-quoted PowerShell string.""" + return (value or "").replace("'", "''") + def _fnv1a_32(value): """Compute a deterministic non-cryptographic 32-bit hash. @@ -295,7 +299,7 @@ def _ensure_parent_directory(ctx, path, debug): "-NoProfile", "-NonInteractive", "-Command", - "New-Item -ItemType Directory -Force -Path '%s' | Out-Null" % win_dir.replace("'", "''"), + "New-Item -ItemType Directory -Force -Path '%s' | Out-Null" % _powershell_single_quote_literal(win_dir), ] res = ctx.execute(ps_cmd) else: @@ -348,7 +352,7 @@ def _validate_abs_path_command_input_or_fail(abs_path): if ("\n" in abs_path) or ("\r" in abs_path) or ("\t" in abs_path): fail("test_optimization_sync: absolute path contains unsupported control characters: %s" % repr(abs_path)) -def _try_read_abs_file(ctx, abs_path, debug): +def _try_read_abs_file(ctx, abs_path): """Best-effort absolute file read with explicit miss/read-error signaling.""" # Returns a status dict: @@ -409,7 +413,7 @@ def _try_read_abs_file(ctx, abs_path, debug): def _build_windows_exists_abs_file_command(abs_path): """Build PowerShell command string for absolute-file existence checks.""" _validate_abs_path_command_input_or_fail(abs_path) - return "$p = '%s'; if (Test-Path -LiteralPath $p -PathType Leaf) { exit 0 } else { exit 3 }" % abs_path.replace("'", "''") + return "$p = '%s'; if (Test-Path -LiteralPath $p -PathType Leaf) { exit 0 } else { exit 3 }" % _powershell_single_quote_literal(abs_path) def _build_unix_exists_abs_file_command(abs_path): """Build POSIX shell command string for absolute-file existence checks.""" @@ -422,7 +426,7 @@ def _build_windows_read_abs_file_command(abs_path): _validate_abs_path_command_input_or_fail(abs_path) # Security note: single quotes are doubled for PowerShell literal strings. - return "$p = '%s'; Get-Content -Raw -LiteralPath $p" % abs_path.replace("'", "''") + return "$p = '%s'; Get-Content -Raw -LiteralPath $p" % _powershell_single_quote_literal(abs_path) def _build_unix_read_abs_file_command(abs_path): """Build shell command string for `_try_read_abs_file` reads.""" @@ -485,7 +489,7 @@ def _detect_go_module_path(ctx, debug): for root in candidates: go_mod_path = root.rstrip("/") + "/go.mod" log_debug(debug, "go", "Checking go.mod at: %s" % go_mod_path) - read_result = _try_read_abs_file(ctx, go_mod_path, debug) + read_result = _try_read_abs_file(ctx, go_mod_path) if read_result.get("ok"): content = read_result.get("value") or "" mp = _parse_go_module_path(content) @@ -505,7 +509,7 @@ def _detect_go_module_path(ctx, debug): if top: go_mod_path = top.rstrip("/") + "/go.mod" log_debug(debug, "go", "Checking go.mod at: %s" % go_mod_path) - read_result = _try_read_abs_file(ctx, go_mod_path, debug) + read_result = _try_read_abs_file(ctx, go_mod_path) if read_result.get("ok"): content = read_result.get("value") or "" mp = _parse_go_module_path(content) @@ -952,8 +956,8 @@ def _http_request(ctx, method, url, headers, out_file, debug, data_file = None, lines = [] lines.append("$ErrorActionPreference = 'Stop'") lines.append("$ProgressPreference = 'SilentlyContinue'") - lines.append("$Url = '%s'" % url.replace("'", "''")) - lines.append("$OutFile = '%s'" % out_file.replace("'", "''")) + lines.append("$Url = '%s'" % _powershell_single_quote_literal(url)) + lines.append("$OutFile = '%s'" % _powershell_single_quote_literal(out_file)) lines.append("$Method = '%s'" % http_method) # Headers hashtable (PowerShell expects IDictionary-like; hashtable is safest) @@ -967,15 +971,15 @@ def _http_request(ctx, method, url, headers, out_file, debug, data_file = None, ps_env["DD_TEST_OPTIMIZATION_API_KEY"] = header_value lines.append("$apiKey = $env:DD_TEST_OPTIMIZATION_API_KEY") lines.append("if ([string]::IsNullOrEmpty($apiKey)) { Write-Error 'missing DD_TEST_OPTIMIZATION_API_KEY for DD-API-KEY header'; exit 2 }") - lines.append("$Headers['%s'] = $apiKey" % header_key.replace("'", "''")) + lines.append("$Headers['%s'] = $apiKey" % _powershell_single_quote_literal(header_key)) else: - lines.append("$Headers['%s'] = '%s'" % (header_key.replace("'", "''"), header_value.replace("'", "''"))) + lines.append("$Headers['%s'] = '%s'" % (_powershell_single_quote_literal(header_key), _powershell_single_quote_literal(header_value))) # Optional body file if data_file: # Keep body on disk and pass `-InFile` to avoid quoting/encoding # drift for JSON payloads that may contain special characters. - lines.append("$BodyFile = '%s'" % data_file.replace("'", "''")) + lines.append("$BodyFile = '%s'" % _powershell_single_quote_literal(data_file)) lines.append("$max = %d; $attempt = 0" % policy["retry_attempts"]) lines.append("while ($true) {") lines.append(" try {") @@ -1062,7 +1066,7 @@ def _http_request(ctx, method, url, headers, out_file, debug, data_file = None, "-NoProfile", "-NonInteractive", "-Command", - "$fi = Get-Item -LiteralPath '%s'; if ($fi) { Write-Output $fi.Length }" % out_file.replace("'", "''"), + "$fi = Get-Item -LiteralPath '%s'; if ($fi) { Write-Output $fi.Length }" % _powershell_single_quote_literal(out_file), ] size_result = ctx.execute(size_cmd) if size_result.return_code == 0 and size_result.stdout: diff --git a/tools/core/test_optimization_uploader.bzl b/tools/core/test_optimization_uploader.bzl index 74d1c3cf..6532d933 100644 --- a/tools/core/test_optimization_uploader.bzl +++ b/tools/core/test_optimization_uploader.bzl @@ -48,12 +48,10 @@ load( # NOTE: `UPLOADER_VERSION` and `RULES_VERSION` are intentionally independent. # Uploader runtime behavior can evolve without forcing a rules contract bump, # while payload metadata still carries both for observability. -load("//tools/core:uploader_bash_template.bzl", "UPLOADER_BASH_TEMPLATE") -load("//tools/core:uploader_batch_template.bzl", "UPLOADER_BATCH_TEMPLATE") -load("//tools/core:uploader_powershell_template.bzl", "UPLOADER_POWERSHELL_TEMPLATE") def _render_template(template, substitutions): """Render script template placeholders with literal-brace support.""" + # Single-pass renderer: # - Supports {key} placeholders. # - Supports escaped literal braces via {{ and }}. @@ -133,8 +131,25 @@ def _base_template_substitutions( "rules_version": RULES_VERSION, } +def _tokenize_template_substitutions(substitutions): + """Convert logical substitution keys to template token placeholders.""" + tokenized = {} + for key, value in substitutions.items(): + value_str = str(value) + for forbidden in ["\n", "\r", "\t"]: + if forbidden in value_str: + fail("dd_payload_uploader: template substitution '%s' contains control characters" % key) + + # Guard against shell/script-breaking interpolation primitives. + for forbidden in ["\"", "$", "`"]: + if forbidden in value_str: + fail("dd_payload_uploader: template substitution '%s' contains unsupported character '%s'" % (key, forbidden)) + tokenized["__DDTPL_%s__" % key.upper()] = value_str + return tokenized + def _bash_curl_retry_flags_for_tests(): """Expose uploader curl retry defaults for unit tests.""" + # Keep the baseline retry behavior compatible with older curl releases. return ["--retry", "3", "--retry-delay", "2", "--retry-connrefused"] @@ -282,6 +297,7 @@ def _is_gitlab_section_header_pattern_for_tests(pattern): return True if ("-" in inner) or ("!" in inner) or ("^" in inner) or ("\\" in inner): return False + # Preserve all-uppercase/digit class sets such as [ABCD] and [A1B2C3]. all_upper_or_digit = True for i in range(len(inner)): @@ -291,6 +307,7 @@ def _is_gitlab_section_header_pattern_for_tests(pattern): break if all_upper_or_digit: return False + # Preserve short alnum bracket classes (for example [xy], [ABC], [Abc]). if len(inner) <= 3: all_alnum = True @@ -301,6 +318,7 @@ def _is_gitlab_section_header_pattern_for_tests(pattern): break if all_alnum: return False + # Preserve plain lowercase/digit class sets such as [abc] and [a1b2]. all_lower_or_digit = True for i in range(len(inner)): @@ -339,12 +357,14 @@ def _is_gitlab_section_header_pattern_powershell_for_tests(pattern): inner = pattern[1:-1] if not inner or ("[" in inner) or ("]" in inner): return False + # Keep PowerShell behavior aligned with script implementation: section # headers are detected via space/tab within bracket content. if (" " in inner) or ("\t" in inner): return True if ("-" in inner) or ("!" in inner) or ("^" in inner) or ("\\" in inner): return False + # Preserve all-uppercase/digit class sets such as [ABCD] and [A1B2C3]. all_upper_or_digit = True for i in range(len(inner)): @@ -444,6 +464,7 @@ def _trim_ascii_whitespace_for_tests(value): def _strip_bom_prefix_for_tests(value): """Remove UTF-8 BOM marker used in manifest parser test fixtures.""" + # Tests use an ASCII marker to represent UTF-8 BOM-prefixed manifest keys. bom_marker = "\\ufeff" if value.startswith(bom_marker): @@ -538,6 +559,7 @@ def _uploader_impl(ctx): The generated scripts perform runtime payload discovery/enrichment/upload, while this function stays analysis-time only (template rendering + runfiles). """ + # `_uploader_impl` is responsible for generating *all* runtime uploader # artifacts. It does not upload anything itself; it emits executable scripts # that run during `bazel run`. @@ -616,11 +638,8 @@ def _uploader_impl(ctx): log_debug(debug, "inputs", " data file: %s (%s)" % (f.basename, f.short_path)) # ------------------------------------------------------------------ - # Phase 2: Render Bash runtime implementation. + # Phase 2: Materialize Bash runtime implementation from template file. # ------------------------------------------------------------------ - # Bash implementation (Unix) - bash_template = UPLOADER_BASH_TEMPLATE - bash_substitutions = _base_template_substitutions( quiescent_sec, max_wait_sec, @@ -637,49 +656,54 @@ def _uploader_impl(ctx): schema_validator_path, ) bash_substitutions["curl_retry_flags"] = " ".join(_bash_curl_retry_flags_for_tests()) - bash_script = _render_template(bash_template, bash_substitutions) - log_debug(debug, "render", "Bash script rendered (bytes=%d)" % len(bash_script)) - - # PowerShell implementation (Windows) - ps_template = UPLOADER_POWERSHELL_TEMPLATE + bash_file = ctx.actions.declare_file(ctx.label.name + ".sh") + ctx.actions.expand_template( + template = ctx.file._bash_runtime_template, + output = bash_file, + substitutions = _tokenize_template_substitutions(bash_substitutions), + is_executable = True, + ) + log_debug(debug, "render", "Bash script rendered from template: %s" % ctx.file._bash_runtime_template.short_path) # ------------------------------------------------------------------ - # Phase 3: Render PowerShell runtime implementation. + # Phase 3: Materialize PowerShell runtime implementation from template file. # ------------------------------------------------------------------ - ps_script = _render_template( - ps_template, - _base_template_substitutions( - quiescent_sec, - max_wait_sec, - fail_on_error, - debug, - keep_payloads, - filter_prefix_enabled, - gzip_payloads, - context_json_rloc, - context_json_path, - schema_json_rloc, - schema_json_path, - schema_validator_rloc, - schema_validator_path, + ps_file = ctx.actions.declare_file(ctx.label.name + ".ps1") + ctx.actions.expand_template( + template = ctx.file._powershell_runtime_template, + output = ps_file, + substitutions = _tokenize_template_substitutions( + _base_template_substitutions( + quiescent_sec, + max_wait_sec, + fail_on_error, + debug, + keep_payloads, + filter_prefix_enabled, + gzip_payloads, + context_json_rloc, + context_json_path, + schema_json_rloc, + schema_json_path, + schema_validator_rloc, + schema_validator_path, + ), ), + is_executable = False, ) - log_debug(debug, "render", "PowerShell script rendered (bytes=%d)" % len(ps_script)) + log_debug(debug, "render", "PowerShell script rendered from template: %s" % ctx.file._powershell_runtime_template.short_path) # ------------------------------------------------------------------ # Phase 4: Materialize executable/script artifacts. # ------------------------------------------------------------------ - # Emit scripts - bash_file = ctx.actions.declare_file(ctx.label.name + ".sh") - ctx.actions.write(output = bash_file, content = bash_script, is_executable = True) - ps_file = ctx.actions.declare_file(ctx.label.name + ".ps1") - ctx.actions.write(output = ps_file, content = ps_script, is_executable = False) - # Create a batch file wrapper for native Windows (calls PowerShell) - bat_template = UPLOADER_BATCH_TEMPLATE - bat_script = bat_template.replace("{ps_name}", ps_file.basename) bat_file = ctx.actions.declare_file(ctx.label.name + ".bat") - ctx.actions.write(output = bat_file, content = bat_script, is_executable = True) + ctx.actions.expand_template( + template = ctx.file._batch_runtime_template, + output = bat_file, + substitutions = _tokenize_template_substitutions({"ps_name": ps_file.basename}), + is_executable = True, + ) log_debug(debug, "outputs", "Declared outputs → bash='%s', ps='%s', bat='%s'" % (bash_file.basename, ps_file.basename, bat_file.basename)) # ------------------------------------------------------------------ @@ -717,6 +741,10 @@ _dd_payload_uploader_rule = rule( # Schema + validator bundled for best-effort payload validation "_schema": attr.label(default = "//tools/core:schemas/agentless-schema.json", allow_single_file = True), "_schema_validator": attr.label(default = "//tools/core:validate_payload_schema.py", allow_single_file = True), + # Runtime templates (kept as standalone files, not inline Starlark strings) + "_bash_runtime_template": attr.label(default = "//tools/core:uploader_bash_runtime.sh.tpl", allow_single_file = True), + "_powershell_runtime_template": attr.label(default = "//tools/core:uploader_powershell_runtime.ps1.tpl", allow_single_file = True), + "_batch_runtime_template": attr.label(default = "//tools/core:uploader_batch_runtime.bat.tpl", allow_single_file = True), # Private attribute to detect Windows platform "_windows_constraint": attr.label(default = "@platforms//os:windows"), }, diff --git a/tools/core/uploader_bash_runtime.sh.tpl b/tools/core/uploader_bash_runtime.sh.tpl new file mode 100644 index 00000000..7f08959f --- /dev/null +++ b/tools/core/uploader_bash_runtime.sh.tpl @@ -0,0 +1,2055 @@ +#!/usr/bin/env bash +set -euo pipefail + +# NOTE: This is a template file. Placeholders like __DDTPL_QUIESCENT_SEC__ are replaced +# by Starlark during rule execution. Double braces { and } are literal braces +# (escaped for Python .format() compatibility). + +# Logging functions (defined first so other functions can use them) +# DEBUG is set later, so we use a function that checks the variable at runtime +log() { echo "[dd-uploader] $1"; } +DEBUG_BOOTSTRAP=$(echo "${DD_TEST_OPTIMIZATION_DEBUG:-0}" | tr '[:upper:]' '[:lower:]') +# Handle dbg behavior. +dbg() { + local dbg_val="${DEBUG:-$DEBUG_BOOTSTRAP}" + dbg_val=$(echo "$dbg_val" | tr '[:upper:]' '[:lower:]') + if [[ "$dbg_val" == "1" || "$dbg_val" == "true" || "$dbg_val" == "yes" ]]; then + echo "[dd-uploader][dbg] $1" >&2 + fi +} +dbg "startup runfiles env: RUNFILES_DIR='${RUNFILES_DIR:-}' RUNFILES_MANIFEST_FILE='${RUNFILES_MANIFEST_FILE:-}' script='$0'" + +# Handle trim ascii whitespace behavior. +trim_ascii_whitespace() { + local value="$1" + value="${value#"${value%%[!$' +']*}"}" + value="${value%"${value##*[!$' +']}"}" + printf '%s +' "$value" +} + +# Handle normalize dd site or fail behavior. +normalize_dd_site_or_fail() { + local raw="$1" + local site + site=$(trim_ascii_whitespace "$raw") + if [[ -z "$site" ]]; then + echo "datadoghq.com" + return 0 + fi + + # Keep compatibility with legacy DD_SITE input shapes. + if [[ "$site" == *"://"* ]]; then + site="${site#*://}" + fi + site="${site%%/*}" + site="${site%%\?*}" + site="${site%%#*}" + if [[ "$site" == app.* ]]; then site="${site#app.}"; fi + if [[ "$site" == api.* ]]; then site="${site#api.}"; fi + site=$(echo "$site" | tr '[:upper:]' '[:lower:]') + site=$(trim_ascii_whitespace "$site") + + if [[ -z "$site" ]]; then + log "error: DD_SITE resolved to an empty hostname (input: '$raw')" + return 1 + fi + if [[ "$site" == *"@"* ]]; then + log "error: DD_SITE must not include credentials/userinfo: '$raw'" + return 1 + fi + if [[ "$site" == *":"* ]]; then + log "error: DD_SITE must be a hostname without an explicit port: '$raw'" + return 1 + fi + if [[ "$site" == .* || "$site" == *. || "$site" == *..* ]]; then + log "error: DD_SITE must be a valid hostname: '$raw'" + return 1 + fi + if [[ ! "$site" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?([.][a-z0-9]([a-z0-9-]*[a-z0-9])?)*$ ]]; then + log "error: DD_SITE contains unsupported hostname characters: '$raw'" + return 1 + fi + echo "$site" +} + +# Resolve runfile path for context.json lookup +# Since `bazel run` does NOT set TEST_SRCDIR, we use RUNFILES_DIR or RUNFILES_MANIFEST_FILE +resolve_runfile() { + local input_rloc="$1" + local rloc="$input_rloc" + # Normalize relative prefixes that can appear in bzlmod runfile paths + rloc="${rloc#./}" + while [[ "$rloc" == ../* ]]; do + rloc="${rloc#../}" + done + # Defensive guard: runfile labels must remain repository-relative. + # We intentionally reject absolute paths and parent traversal segments so + # runfile resolution cannot escape the runfiles tree. + if [[ -z "$rloc" || "$rloc" == /* || "$rloc" =~ ^[A-Za-z]:/ || "$rloc" == ".." || "$rloc" == */.. || "$rloc" == */../* ]]; then + dbg "resolve_runfile: rejected suspicious runfile label '$input_rloc' (normalized='$rloc')" + echo "" + return + fi + local candidates=("$rloc") + if [[ "$rloc" == external/* ]]; then + candidates+=("${rloc#external/}") + else + # Try the external/ prefix when short_path omits it under bzlmod. + candidates+=("external/$rloc") + fi + if [[ "$rloc" != _main/* ]]; then + candidates+=("_main/$rloc") + fi + local manifest_file="${RUNFILES_MANIFEST_FILE:-}" + dbg "resolve_runfile: input='$input_rloc' normalized='$rloc' candidates='${candidates[*]}'" + if [[ -n "${RUNFILES_DIR:-}" ]]; then + local rf_state="missing" + if [[ -d "$RUNFILES_DIR" ]]; then + rf_state="dir" + elif [[ -e "$RUNFILES_DIR" ]]; then + rf_state="exists_non_dir" + fi + dbg "resolve_runfile: RUNFILES_DIR='$RUNFILES_DIR' state=$rf_state" + else + dbg "resolve_runfile: RUNFILES_DIR=" + fi + if [[ -n "$manifest_file" ]]; then + local mf_state="missing" + if [[ -f "$manifest_file" ]]; then + mf_state="file" + elif [[ -e "$manifest_file" ]]; then + mf_state="exists_non_file" + fi + dbg "resolve_runfile: RUNFILES_MANIFEST_FILE='$manifest_file' state=$mf_state" + else + dbg "resolve_runfile: RUNFILES_MANIFEST_FILE=" + fi + for cand in "${candidates[@]}"; do + dbg "resolve_runfile: trying candidate '$cand'" + # Try RUNFILES_DIR first (Unix default) + if [[ -n "${RUNFILES_DIR:-}" && -f "$RUNFILES_DIR/$cand" ]]; then + dbg "resolve_runfile: hit RUNFILES_DIR -> '$RUNFILES_DIR/$cand'" + echo "$RUNFILES_DIR/$cand" + return + fi + # Try $0.runfiles fallback + if [[ -f "$0.runfiles/$cand" ]]; then + dbg "resolve_runfile: hit script runfiles -> '$0.runfiles/$cand'" + echo "$0.runfiles/$cand" + return + fi + # Try RUNFILES_MANIFEST_FILE (Windows/manifest-only) + if [[ -n "$manifest_file" && -f "$manifest_file" ]]; then + local path + # Pass 1: exact manifest key match (preferred). + # Use awk + substr() for regex-free extraction, so candidate labels + # containing regex metacharacters are treated as plain text. + # We also strip a UTF-8 BOM from the first manifest key for parity + # with PowerShell and editors/tools that emit BOM-prefixed files. + path=$(awk -v key="$cand" ' + BEGIN { bom = sprintf("%c%c%c", 239, 187, 191) } + { + k = $1 + if (NR == 1 && index(k, bom) == 1) { + k = substr(k, 4) + } + if (k == key) { + print substr($0, length($1) + 2) + exit + } + } + ' "$manifest_file") + path=$(trim_ascii_whitespace "$path") + if [[ -n "$path" ]]; then + if [[ -f "$path" ]]; then + dbg "resolve_runfile: hit manifest exact key '$cand' -> '$path'" + echo "$path" + return + fi + dbg "resolve_runfile: manifest exact key '$cand' -> '$path' (not a file)" + fi + # Fallback: some manifests prefix keys with repo names (for example "/path/to/file"). + # Match entries whose key ends with "/" or "\". + # Pass 2: suffix match for repo-prefixed key variants. + path=$(awk -v key="$cand" ' + BEGIN { bom = sprintf("%c%c%c", 239, 187, 191) } + { + k = $1 + if (NR == 1 && index(k, bom) == 1) { + k = substr(k, 4) + } + if (length(k) > length(key) && substr(k, length(k) - length(key) + 1) == key) { + sep = substr(k, length(k) - length(key), 1) + if (sep == "/" || sep == "\\") { + print substr($0, length($1) + 2) + exit + } + } + } + ' "$manifest_file") + path=$(trim_ascii_whitespace "$path") + if [[ -n "$path" ]]; then + if [[ -f "$path" ]]; then + dbg "resolve_runfile: hit manifest suffix key '$cand' -> '$path'" + echo "$path" + return + fi + dbg "resolve_runfile: manifest suffix key '$cand' -> '$path' (not a file)" + fi + fi + done + dbg "resolve_runfile: miss for input '$input_rloc'" + echo "" # Not found +} + +# Resolve execroot-relative artifact path (File.path). +# Bazel commonly provides paths like "external//..." relative to execroot. +resolve_artifact_path() { + local input_path="$1" + if [[ -z "$input_path" ]]; then + echo "" + return + fi + dbg "resolve_artifact_path: input='$input_path'" + if [[ -f "$input_path" ]]; then + dbg "resolve_artifact_path: hit direct -> '$input_path'" + echo "$input_path" + return + fi + local script_dir execroot candidate + script_dir=$(cd "$(dirname "$0")" && pwd -P) + execroot=$(cd "$script_dir/../../.." 2>/dev/null && pwd -P || true) + if [[ -n "$execroot" ]]; then + candidate="$execroot/$input_path" + if [[ -f "$candidate" ]]; then + dbg "resolve_artifact_path: hit execroot-relative -> '$candidate'" + echo "$candidate" + return + fi + fi + dbg "resolve_artifact_path: miss for input '$input_path'" + echo "" +} + +# Resolve context.json path (used by upload functions for payload enrichment) +# Path is determined at rule implementation time from data files +CONTEXT_JSON_RLOC="__DDTPL_CONTEXT_JSON_RLOC__" +CONTEXT_JSON_PATH="__DDTPL_CONTEXT_JSON_PATH__" +dbg "context.json resolution inputs: path='$CONTEXT_JSON_PATH' rloc='$CONTEXT_JSON_RLOC'" +CONTEXT_JSON=$(resolve_artifact_path "$CONTEXT_JSON_PATH") +if [[ -n "$CONTEXT_JSON" ]]; then + # Direct artifact path is fastest and most deterministic when available. + dbg "context.json resolved via direct path: '$CONTEXT_JSON'" +elif [[ -n "$CONTEXT_JSON_RLOC" ]]; then + # Runfiles lookup supports launcher/platform variants and bzlmod naming. + CONTEXT_JSON=$(resolve_runfile "$CONTEXT_JSON_RLOC") + if [[ -z "$CONTEXT_JSON" ]]; then + log "warning: context.json not found in runfiles; payloads will not be enriched" + else + dbg "context.json resolved via runfiles: '$CONTEXT_JSON'" + fi +else + CONTEXT_JSON="" + dbg "context.json not configured in data files; enrichment disabled" +fi + +# Resolve schema and validator paths (used for payload validation) +SCHEMA_JSON_RLOC="__DDTPL_SCHEMA_JSON_RLOC__" +SCHEMA_JSON_PATH="__DDTPL_SCHEMA_JSON_PATH__" +SCHEMA_VALIDATOR_RLOC="__DDTPL_SCHEMA_VALIDATOR_RLOC__" +SCHEMA_VALIDATOR_PATH="__DDTPL_SCHEMA_VALIDATOR_PATH__" +dbg "schema resolution inputs: schema_path='$SCHEMA_JSON_PATH' schema_rloc='$SCHEMA_JSON_RLOC' validator_path='$SCHEMA_VALIDATOR_PATH' validator_rloc='$SCHEMA_VALIDATOR_RLOC'" +SCHEMA_JSON=$(resolve_artifact_path "$SCHEMA_JSON_PATH") +if [[ -n "$SCHEMA_JSON" ]]; then + dbg "schema resolved via direct path: '$SCHEMA_JSON'" +elif [[ -n "$SCHEMA_JSON_RLOC" ]]; then + # Fallback to runfiles so validation still works under manifest-only setups. + SCHEMA_JSON=$(resolve_runfile "$SCHEMA_JSON_RLOC") + if [[ -z "$SCHEMA_JSON" ]]; then + log "warning: schema not found in runfiles; validation disabled" + else + dbg "schema resolved via runfiles: '$SCHEMA_JSON'" + fi +else + SCHEMA_JSON="" + dbg "schema not configured in data files; validation disabled" +fi +SCHEMA_VALIDATOR=$(resolve_artifact_path "$SCHEMA_VALIDATOR_PATH") +if [[ -n "$SCHEMA_VALIDATOR" ]]; then + dbg "schema validator resolved via direct path: '$SCHEMA_VALIDATOR'" +elif [[ -n "$SCHEMA_VALIDATOR_RLOC" ]]; then + # Keep parity with schema resolution order (direct path first, runfile second). + SCHEMA_VALIDATOR=$(resolve_runfile "$SCHEMA_VALIDATOR_RLOC") + if [[ -z "$SCHEMA_VALIDATOR" ]]; then + log "warning: schema validator not found in runfiles; validation disabled" + else + dbg "schema validator resolved via runfiles: '$SCHEMA_VALIDATOR'" + fi +else + SCHEMA_VALIDATOR="" + dbg "schema validator not configured in data files; validation disabled" +fi + +# Normalize boolean value (handles True/False from Starlark, 1/0, true/false) +# Uses tr for POSIX compatibility (macOS ships with Bash 3.2 which lacks ${var,,}) +normalize_bool() { + local val + val=$(echo "$1" | tr '[:upper:]' '[:lower:]') + case "$val" in + 1|true|yes) echo "1" ;; + *) echo "0" ;; + esac +} + +# Validate numeric value; exit 2 if invalid +validate_numeric() { + local name="$1" + local val="$2" + if ! [[ "$val" =~ ^[0-9]+$ ]]; then + log "error: $name must be a non-negative integer, got: '$val'" + exit 2 # Configuration error + fi +} + +# Generate UUID (best effort). Uses uuidgen, python3, or /dev/urandom. +generate_uuid() { + if command -v uuidgen >/dev/null 2>&1; then + uuidgen | tr '[:upper:]' '[:lower:]' + return + fi + if command -v python3 >/dev/null 2>&1; then + python3 - <<'PY' +import uuid +print(str(uuid.uuid4())) +PY + return + fi + if [[ -r /dev/urandom ]]; then + local hex + hex=$(od -An -N16 -tx1 /dev/urandom | tr -d ' +') + echo "${hex:0:8}-${hex:8:4}-${hex:12:4}-${hex:16:4}-${hex:20:12}" + return + fi + echo "00000000-0000-0000-0000-000000000000" +} + +# Compute FNV-1a 32-bit hex fingerprint (non-cryptographic, for parity checks only) +fnv1a_32() { + local input="$1" + if [[ -z "$input" ]]; then + echo "" + return + fi + local alphabet=$'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_:/.+@=#%~!$^*()[]{}<>?,;|\\\"\'` ' + local hash=2166136261 + local input_len="${#input}" + local alpha_len="${#alphabet}" + local i j idx found ch ach + for ((i = 0; i < input_len; i++)); do + ch="${input:i:1}" + idx=0 + found=0 + for ((j = 0; j < alpha_len; j++)); do + ach="${alphabet:j:1}" + if [[ "$ach" == "$ch" ]]; then + idx=$j + found=1 + break + fi + done + if (( found == 0 )); then + # Keep unknown-character bucketing aligned with sync-side Starlark logic. + idx=$((alpha_len + (i % 7))) + fi + hash=$((hash ^ idx)) + hash=$(( (hash * 16777619) & 0xffffffff )) + done + printf '%08x' "$hash" +} + +# Rule attributes (can be overridden via environment variables) +QUIESCENT_SEC=${DD_TEST_OPTIMIZATION_QUIESCENT_SEC:-__DDTPL_QUIESCENT_SEC__} +MAX_WAIT_SEC=${DD_TEST_OPTIMIZATION_MAX_WAIT_SEC:-__DDTPL_MAX_WAIT_SEC__} +FAIL_ON_ERROR=$(normalize_bool "__DDTPL_FAIL_ON_ERROR__") +KEEP_PAYLOADS=$(normalize_bool "${DD_TEST_OPTIMIZATION_KEEP_PAYLOADS:-__DDTPL_KEEP_PAYLOADS__}") +FILTER_PREFIX=$(normalize_bool "${DD_TEST_OPTIMIZATION_FILTER_PREFIX:-__DDTPL_FILTER_PREFIX__}") +DEBUG=$(normalize_bool "${DD_TEST_OPTIMIZATION_DEBUG:-__DDTPL_DEBUG__}") +GZIP_PAYLOADS=$(normalize_bool "${DD_TEST_OPTIMIZATION_GZIP:-__DDTPL_GZIP_PAYLOADS__}") +RULES_VERSION="__DDTPL_RULES_VERSION__" +RUNTIME_ID=$(generate_uuid) + +# Validate numeric environment variables +validate_numeric "QUIESCENT_SEC" "$QUIESCENT_SEC" +validate_numeric "MAX_WAIT_SEC" "$MAX_WAIT_SEC" +if [[ -n "${DD_TEST_OPTIMIZATION_MAX_DEPTH:-}" ]]; then + validate_numeric "DD_TEST_OPTIMIZATION_MAX_DEPTH" "$DD_TEST_OPTIMIZATION_MAX_DEPTH" +fi +if [[ "$GZIP_PAYLOADS" == "1" ]]; then + if ! command -v gzip >/dev/null 2>&1; then + log "warning: DD_TEST_OPTIMIZATION_GZIP=1 but gzip not found; disabling gzip" + GZIP_PAYLOADS=0 + fi +fi +dbg "gzip enabled: $GZIP_PAYLOADS" + +# Baseline curl retry flags. We append --retry-all-errors only when supported +# by the installed curl binary (introduced in curl 7.85.0). +CURL_RETRY_FLAGS=(__DDTPL_CURL_RETRY_FLAGS__) +if curl --help all 2>/dev/null | grep -q -- '--retry-all-errors'; then + CURL_RETRY_FLAGS+=(--retry-all-errors) +fi +dbg "curl retry flags: ${CURL_RETRY_FLAGS[*]}" + +# Acquire exclusive lock to prevent concurrent uploaders +# Uses mkdir for portability (works on macOS which lacks flock) +# Lock is scoped to workspace to allow parallel uploads in different workspaces +# Hash generation handles both Linux (md5sum) and macOS (md5 -q) formats +compute_workspace_hash() { + local workspace="${BUILD_WORKSPACE_DIRECTORY:-$(pwd)}" + # Try md5sum (Linux), then md5 -q (macOS), then shasum, then fallback + if command -v md5sum >/dev/null 2>&1; then + printf "%s" "$workspace" | md5sum | cut -c1-8 + elif command -v md5 >/dev/null 2>&1; then + printf "%s" "$workspace" | md5 -q | cut -c1-8 + elif command -v shasum >/dev/null 2>&1; then + printf "%s" "$workspace" | shasum -a 256 | cut -c1-8 + else + echo "default" + fi +} +WORKSPACE_HASH=$(compute_workspace_hash) +LOCK_DIR="${TMPDIR:-/tmp}/dd_upload_payloads_$WORKSPACE_HASH.lock" +LOCK_ACQUIRED=0 + +# Handle lock dir age seconds behavior. +lock_dir_age_seconds() { + local dir="$1" + local now mtime + # Cross-platform stat: + # - BSD/macOS: stat -f %m + # - GNU/Linux: stat -c %Y + now=$(date +%s) + if mtime=$(stat -f %m "$dir" 2>/dev/null); then + : + elif mtime=$(stat -c %Y "$dir" 2>/dev/null); then + : + else + echo 0 + return + fi + if [[ "$mtime" =~ ^[0-9]+$ ]]; then + echo $(( now - mtime )) + else + echo 0 + fi +} + +# Handle acquire lock behavior. +acquire_lock() { + local max_attempts=3 + local attempt=0 + while (( attempt < max_attempts )); do + if mkdir "$LOCK_DIR" 2>/dev/null; then + # Persist PID metadata right after lock creation. If this write fails + # we treat the lock as unusable and immediately remove it. + if ! echo $$ > "$LOCK_DIR/pid" 2>/dev/null; then + rm -rf "$LOCK_DIR" 2>/dev/null || true + log "error: failed to initialize lock metadata at $LOCK_DIR/pid" + return 1 + fi + LOCK_ACQUIRED=1 + dbg "acquired lock: $LOCK_DIR (workspace hash: $WORKSPACE_HASH)" + return 0 + fi + # Check if lock is stale: + # 1) lock dir exists but pid file is empty/malformed + # 2) lock dir exists but pid file is missing + # 3) pid exists but process is no longer alive + if [[ -f "$LOCK_DIR/pid" ]]; then + local owner_pid + owner_pid=$(tr -d '[:space:]' < "$LOCK_DIR/pid" 2>/dev/null || echo "") + if [[ -z "$owner_pid" ]]; then + local lock_age + lock_age=$(lock_dir_age_seconds "$LOCK_DIR") + if [[ "$lock_age" =~ ^[0-9]+$ ]] && (( lock_age > 30 )); then + dbg "removing stale lock (empty pid file, age ${lock_age}s)" + rm -rf "$LOCK_DIR" 2>/dev/null || true + ((++attempt)) + continue + fi + ((++attempt)) + sleep 1 + continue + fi + if ! kill -0 "$owner_pid" 2>/dev/null; then + dbg "removing stale lock (pid $owner_pid is dead)" + rm -rf "$LOCK_DIR" 2>/dev/null || true + ((++attempt)) + continue + fi + else + local lock_age + lock_age=$(lock_dir_age_seconds "$LOCK_DIR") + if [[ "$lock_age" =~ ^[0-9]+$ ]] && (( lock_age > 30 )); then + dbg "removing stale lock (missing pid file, age ${lock_age}s)" + rm -rf "$LOCK_DIR" 2>/dev/null || true + ((++attempt)) + continue + fi + # Fresh lock without pid metadata might be in the middle of setup by + # another uploader; back off briefly before retrying. + ((++attempt)) + sleep 1 + continue + fi + log "error: another uploader is already running (lock: $LOCK_DIR)" + log "hint: wait for the other uploader to finish, or remove the lock directory if stale" + return 1 + done + return 1 +} + +if ! acquire_lock; then + exit 2 +fi + +# Temporary working directory for enriched payloads / multipart event files +TMP_PAYLOAD_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dd_topt_payloads.XXXXXX" 2>/dev/null || true)" +if [[ -z "$TMP_PAYLOAD_DIR" || ! -d "$TMP_PAYLOAD_DIR" ]]; then + log "error: failed to create temp directory for payload uploads" + rm -rf "$LOCK_DIR" 2>/dev/null || true + exit 2 +fi + +# Cleanup lock on exit +cleanup() { + # Only the lock owner may remove LOCK_DIR. This avoids deleting an active + # uploader's lock when the current process failed to acquire it. + if [[ "$LOCK_ACQUIRED" == "1" ]]; then + rm -rf "$LOCK_DIR" 2>/dev/null || true + fi + rm -rf "$TMP_PAYLOAD_DIR" 2>/dev/null || true +} +trap cleanup EXIT + +# Determine bazel-testlogs directory +# Priority: TESTLOGS_DIR env var > BUILD_WORKSPACE_DIRECTORY/bazel-testlogs > ./bazel-testlogs +# +# NOTE: We intentionally do NOT call `bazel info` from within the uploader. +# Running `bazel info` inside `bazel run` can deadlock when the output base is locked. +# For non-standard setups (--symlink_prefix, disabled symlinks), users should set +# TESTLOGS_DIR externally using the same Bazel binary AND flags as for 'bazel test': +# BAZEL_FLAGS=("--output_base=/custom/base") +# TESTLOGS_DIR=$(bazel "${BAZEL_FLAGS[@]}" info bazel-testlogs) bazel "${BAZEL_FLAGS[@]}" run ... + +# Check explicit TESTLOGS_DIR override first (fail fast if set but invalid) +if [[ -n "${TESTLOGS_DIR:-}" ]]; then + if [[ -d "$TESTLOGS_DIR" ]]; then + # Explicit override wins over all discovery heuristics. + dbg "using explicit TESTLOGS_DIR=$TESTLOGS_DIR" + else + log "error: TESTLOGS_DIR is set but path does not exist: $TESTLOGS_DIR" + log "hint: ensure you used the same Bazel wrapper for 'bazel info' as for 'bazel test'" + exit 2 # Configuration error (see exit codes in docs) + fi +else + # Auto-discover testlogs directory + # Discovery order intentionally mirrors common Bazel invocation contexts: + # 1) BUILD_WORKSPACE_DIRECTORY (when provided by launcher) + # 2) local bazel-testlogs symlink in current directory + if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" ]]; then + candidate="$BUILD_WORKSPACE_DIRECTORY/bazel-testlogs" + if [[ -d "$candidate" ]] || [[ -L "$candidate" ]]; then + TESTLOGS_DIR="$candidate" + fi + fi + + if [[ -z "${TESTLOGS_DIR:-}" ]] && { [[ -d "bazel-testlogs" ]] || [[ -L "bazel-testlogs" ]]; }; then + TESTLOGS_DIR="$(pwd)/bazel-testlogs" + fi + + if [[ -z "${TESTLOGS_DIR:-}" ]]; then + log "warning: testlogs dir not found (nothing to upload)" + log "hint: set TESTLOGS_DIR env var, or ensure bazel-testlogs symlink exists" + # Exit 0 by default (graceful no-op), but respect FAIL_ON_ERROR to catch misconfigurations + if [[ "$FAIL_ON_ERROR" == "1" ]]; then + log "error: FAIL_ON_ERROR is set and no testlogs found - this may indicate misconfiguration" + exit 2 # Configuration error + fi + exit 0 + fi + + dbg "auto-discovered TESTLOGS_DIR=$TESTLOGS_DIR" +fi + +# Find all test.outputs directories +# Supports DD_TEST_OPTIMIZATION_MAX_DEPTH to limit search depth for large testlogs trees +MAX_DEPTH=${DD_TEST_OPTIMIZATION_MAX_DEPTH:-0} +# Handle find test outputs behavior. +find_test_outputs() { + local depth_args=() + if (( MAX_DEPTH > 0 )); then + depth_args=(-maxdepth "$MAX_DEPTH") + dbg "limiting find depth to $MAX_DEPTH" + fi + find "$TESTLOGS_DIR" "${depth_args[@]+"${depth_args[@]}"}" -type d -name "test.outputs" 2>/dev/null || true +} + +# Warn if MAX_DEPTH is set and no test.outputs found (likely depth too shallow) +# Note: Must be called AFTER cache_test_outputs to use the cache +check_depth_warning() { + if [[ -z "$TEST_OUTPUTS_CACHE" ]] && (( MAX_DEPTH > 0 )); then + log "warning: DD_TEST_OPTIMIZATION_MAX_DEPTH=$MAX_DEPTH may be too shallow" + log "hint: typical test.outputs paths require depth 3-5; try increasing or removing the limit" + fi +} + +# Detect stat flavor (BSD vs GNU) to choose correct flags +# GNU stat supports: stat -c %Y / (returns numeric mtime) +# BSD stat supports: stat -f %m / (returns numeric mtime) +STAT_FLAVOR="bsd" +if stat -c %Y / >/dev/null 2>&1; then + STAT_FLAVOR="gnu" +fi +dbg "stat detection: STAT_FLAVOR=$STAT_FLAVOR (uname=$(uname -s))" + +# Get latest mtime across payloads/tests and payloads/coverage in test.outputs. +# Note: Only scans payload directories, not all files under test.outputs +latest_mtime_all() { + local max_mtime=0 + while IFS= read -r outputs_dir; do + [[ -z "$outputs_dir" ]] && continue + for subdir in "payloads/tests" "payloads/coverage"; do + local dir="$outputs_dir/$subdir" + [[ -d "$dir" ]] || continue + local mt + if [[ "$STAT_FLAVOR" == "bsd" ]]; then + mt=$(find "$dir" -type f -name "*.json" -exec stat -f '%m' {} + 2>/dev/null | sort -nr | head -1 || echo 0) + else + mt=$(find "$dir" -type f -name "*.json" -exec stat -c '%Y' {} + 2>/dev/null | sort -nr | head -1 || echo 0) + fi + mt=${mt:-0} + if (( mt > max_mtime )); then + max_mtime=$mt + fi + done + done < <(echo "$TEST_OUTPUTS_CACHE") + echo "$max_mtime" +} + +# Count total payload files across all test.outputs payload directories. +count_payload_files() { + local count=0 + while IFS= read -r outputs_dir; do + [[ -z "$outputs_dir" ]] && continue + local tests_dir="$outputs_dir/payloads/tests" + local cov_dir="$outputs_dir/payloads/coverage" + if [[ -d "$tests_dir" ]]; then + local tests_count + tests_count=$(find "$tests_dir" -name "*.json" 2>/dev/null | wc -l) + count=$((count + tests_count)) + fi + if [[ -d "$cov_dir" ]]; then + local cov_count + cov_count=$(find "$cov_dir" -name "*.json" 2>/dev/null | wc -l) + count=$((count + cov_count)) + fi + done < <(echo "$TEST_OUTPUTS_CACHE") + echo "$count" +} + +start_ts=$(date +%s) +dbg "Uploader start time: $start_ts" + +# Detect if tests actually ran by looking for test.log or test.xml files +# This helps distinguish "no payloads because tests didn't run" from "tests ran but dd-trace-go is misconfigured" +tests_executed() { + local found + found=$(find "$TESTLOGS_DIR" \( -name "test.log" -o -name "test.xml" \) -type f -print -quit 2>/dev/null) + [[ -n "$found" ]] +} + +# Wait for quiescence (filesystem to settle) +# Since the uploader runs AFTER tests complete (via `bazel run` after `bazel test`), +# we just need a short quiescence period to ensure all files are written. +dbg "Waiting for test outputs to quiesce..." + +# Cache the list of test.outputs directories for efficiency (avoid rescanning on each loop iteration) +TEST_OUTPUTS_CACHE="" +# Handle cache test outputs behavior. +cache_test_outputs() { + TEST_OUTPUTS_CACHE=$(find_test_outputs) +} +cache_test_outputs +check_depth_warning # Warn if MAX_DEPTH may be too shallow + +while true; do + now=$(date +%s) + elapsed=$((now - start_ts)) + + # Refresh cache in case new test.outputs dirs appeared (e.g., remote downloads) + cache_test_outputs + total_files=$(count_payload_files) + + if (( total_files == 0 )); then + # No payloads yet. Branch behavior depends on max-wait policy: + # - MAX_WAIT_SEC=0: immediate decision (upload no-op or fail-on-error) + # - MAX_WAIT_SEC>0: keep polling until timeout + if (( MAX_WAIT_SEC == 0 )); then + if tests_executed; then + log "warning: tests ran but no payload files found" + log "hint: check that DD_TEST_OPTIMIZATION_PAYLOADS_IN_FILES=true is set" + if [[ "$FAIL_ON_ERROR" == "1" ]]; then + log "error: FAIL_ON_ERROR is set; failing due to missing payloads" + exit 1 + fi + else + log "no payload files found and no test execution detected; nothing to upload" + fi + exit 0 + fi + if (( elapsed > MAX_WAIT_SEC )); then + if tests_executed; then + log "warning: tests ran but no payload files found" + log "hint: check that DD_TEST_OPTIMIZATION_PAYLOADS_IN_FILES=true is set" + if [[ "$FAIL_ON_ERROR" == "1" ]]; then + log "error: FAIL_ON_ERROR is set; failing due to missing payloads" + exit 1 + fi + else + log "no payload files found and no test execution detected; nothing to upload" + fi + exit 0 + fi + dbg "no payload files yet; waiting" + sleep 2 + continue + fi + + if (( elapsed > MAX_WAIT_SEC )); then + # Payloads exist but waiting budget is exhausted; proceed anyway. + log "max wait exceeded ($MAX_WAIT_SEC s); proceeding to upload" + break + fi + + # Check if files have been stable for QUIESCENT_SEC + cur=$(latest_mtime_all) + idle=$((now - cur)) + dbg "total_files=$total_files, idle=$idle s" + + if (( idle >= QUIESCENT_SEC )); then + log "outputs quiescent for $idle s ($total_files files); starting upload" + break + fi + + sleep 2 +done + +# Build endpoints +if ! DD_SITE="$(normalize_dd_site_or_fail "${DD_SITE:-datadoghq.com}")"; then + exit 2 +fi +INTAKE_BASE="${DD_TEST_OPTIMIZATION_INTAKE_BASE:-}" +if [[ -z "${DD_TRACE_AGENT_URL:-}" ]]; then + # Agentless mode: direct public intake URLs (or explicit override base). + AGENTLESS=1 + if [[ -n "$INTAKE_BASE" ]]; then + # Allow tests/dev to override intake base without changing DD_SITE. + BASE="${INTAKE_BASE%/}" + TEST_URL="${BASE}/api/v2/citestcycle" + COV_URL="${BASE}/api/v2/citestcov" + dbg "DD_TEST_OPTIMIZATION_INTAKE_BASE override active: $BASE" + else + TEST_URL="https://citestcycle-intake.${DD_SITE}/api/v2/citestcycle" + COV_URL="https://citestcov-intake.${DD_SITE}/api/v2/citestcov" + fi +else + # EVP mode: route through agent endpoint with required subdomain headers. + AGENTLESS=0 + TEST_URL="${DD_TRACE_AGENT_URL}/evp_proxy/v2/api/v2/citestcycle" + COV_URL="${DD_TRACE_AGENT_URL}/evp_proxy/v2/api/v2/citestcov" + if [[ -n "$INTAKE_BASE" ]]; then + dbg "DD_TEST_OPTIMIZATION_INTAKE_BASE ignored in EVP mode" + fi +fi +dbg "mode: AGENTLESS=$AGENTLESS DD_SITE=$DD_SITE" +dbg "endpoints: TEST_URL=$TEST_URL COV_URL=$COV_URL" + +HEADER_LANG_DEFAULT="bazel-starlark" +HEADER_LANG_VERSION_DEFAULT="n/a" +HEADER_LANG_INTERPRETER_DEFAULT="bazel-run" +HEADER_TRACER_VERSION_DEFAULT="__DDTPL_UPLOADER_VERSION__" +if (( AGENTLESS == 1 )); then + if [[ -z "${DD_API_KEY:-}" ]]; then + log "error: DD_API_KEY required for agentless uploads" + log "hint: pass credentials via environment: DD_API_KEY=... DD_SITE=... bazel run //:dd_upload_payloads" + exit 2 # Configuration error + fi +else + # EVP subdomain headers per endpoint + TEST_EVP=( -H "X-Datadog-EVP-Subdomain: citestcycle-intake" ) + COV_EVP=( -H "X-Datadog-EVP-Subdomain: citestcov-intake" ) +fi +dbg "headers prepared (agentless=$AGENTLESS; test headers can be derived from metadata)" + +# Redact sensitive header values (keep last 4 chars for DD-API-KEY) +redact_header() { + local h="$1" + local name="${h%%:*}" + if [[ "$name" == "DD-API-KEY" ]]; then + local val="${h#*:}" + val="${val# }"; val="${val% }"; val="${val%%$'\r'}" + if (( ${#val} > 4 )); then + echo "DD-API-KEY: ****${val: -4}" + else + echo "DD-API-KEY: $val" + fi + else + echo "$h" + fi +} + +# Handle dbg headers behavior. +dbg_headers() { + local label="$1"; shift + local arr=("$@") + local i=0 + while (( i < ${#arr[@]} )); do + if [[ "${arr[$i]}" == "-H" && $((i+1)) -lt ${#arr[@]} ]]; then + dbg "header[$label]: $(redact_header "${arr[$((i+1))]}")" + i=$((i+2)) + continue + fi + dbg "header[$label]: ${arr[$i]}" + i=$((i+1)) + done +} + +# Load context.json for enrichment +JQ_AVAILABLE=0 +if command -v jq >/dev/null 2>&1; then JQ_AVAILABLE=1; fi +dbg "jq available: $JQ_AVAILABLE" +dbg "context.json: ${CONTEXT_JSON:-}" + +# CODEOWNERS state (initialized lazily on first enrichment attempt). +CODEOWNERS_INITIALIZED=0 +CODEOWNERS_ENABLED=0 +CODEOWNERS_FILE="" +CODEOWNERS_WORKSPACE_ROOT="" +CODEOWNERS_CONTEXT_WORKSPACE="" +CODEOWNERS_RULE_REGEX=() +CODEOWNERS_RULE_OWNERS=() +CODEOWNERS_RULE_HAS_OWNERS=() +CODEOWNERS_SOURCE_CANDIDATES=() +CODEOWNERS_MATCH_NONE="__DD_CODEOWNERS_NO_MATCH__" +CODEOWNERS_MATCH_EMPTY="__DD_CODEOWNERS_EMPTY_OWNERS__" +CODEOWNERS_SPLIT_PATTERN="" +CODEOWNERS_SPLIT_OWNERS_RAW="" +CO_EVENTS_SCANNED=0 +CO_EVENTS_ENRICHED=0 +CO_EVENTS_SKIPPED_EXISTING=0 +CO_EVENTS_SKIPPED_MISSING_SOURCE=0 +CO_EVENTS_SKIPPED_UNMATCHED=0 +CO_EVENTS_SKIPPED_ERRORS=0 + +# Handle decode percent path behavior. +decode_percent_path() { + local value="$1" + if [[ "$value" != *"%"* ]]; then + echo "$value" + return + fi + # Avoid introducing NUL bytes into shell strings. + if [[ "$value" == *"%00"* ]]; then + echo "$value" + return + fi + # Decode only when every '%' participates in a valid %XX sequence. + # This keeps behavior deterministic for malformed input. + local stripped + stripped=$(echo "$value" | sed -E 's/%[0-9A-Fa-f]{2}//g') + if [[ "$stripped" == *"%"* ]]; then + echo "$value" + return + fi + local decoded + decoded=$(printf '%b' "${value//%/\\x}" 2>/dev/null || true) + if [[ -n "$decoded" ]]; then + echo "$decoded" + else + echo "$value" + fi +} + +# Handle normalize path like behavior. +normalize_path_like() { + local raw="$1" + if [[ "$raw" == file://* ]]; then + raw="${raw#file://}" + fi + raw=$(decode_percent_path "$raw") + # Decode can re-introduce backslashes (for example %5C on Windows paths). + # Normalize after decoding so slash-based matching stays consistent. + raw="${raw//\\//}" + # Collapse duplicated separators to improve matching stability. + while [[ "$raw" == *"//"* ]]; do + raw=$(echo "$raw" | sed -E 's#/{2,}#/#g') + done + while [[ "$raw" == ./* ]]; do + raw="${raw#./}" + done + if [[ "$raw" =~ ^/[A-Za-z]:/ ]]; then + # file:///C:/... style paths become /C:/... after scheme removal. + # Drop only the leading slash to preserve the drive-qualified path. + raw="${raw:1}" + fi + + local is_abs=0 + if [[ "$raw" == /* ]]; then + is_abs=1 + raw="${raw#/}" + fi + + # Canonicalize dot segments. If normalization would escape above root, + # return failure so caller can skip unsafe/invalid candidates. + local -a parts=() + local -a stack=() + local part idx + IFS='/' read -r -a parts <<< "$raw" + for part in "${parts[@]}"; do + case "$part" in + ""|".") + continue + ;; + "..") + if (( ${#stack[@]} > 0 )); then + idx=$(( ${#stack[@]} - 1 )) + unset "stack[$idx]" + stack=("${stack[@]}") + else + echo "" + return 1 + fi + ;; + *) + stack+=("$part") + ;; + esac + done + + local joined="" + if (( ${#stack[@]} > 0 )); then + joined="${stack[0]}" + for ((idx = 1; idx < ${#stack[@]}; idx++)); do + joined="$joined/${stack[$idx]}" + done + fi + + if (( is_abs == 1 )); then + echo "/$joined" + else + echo "$joined" + fi + return 0 +} + +# Handle add path candidate behavior. +add_path_candidate() { + local candidate="$1" + local normalized + normalized=$(normalize_path_like "$candidate" || true) + [[ -z "$normalized" ]] && return + normalized="${normalized#/}" + while [[ "$normalized" == ./* ]]; do + normalized="${normalized#./}" + done + [[ -z "$normalized" ]] && return + # Generated output paths do not map to repository-owned source files. + [[ "$normalized" == bazel-out/* ]] && return + local existing + if (( ${#CODEOWNERS_SOURCE_CANDIDATES[@]} > 0 )); then + for existing in "${CODEOWNERS_SOURCE_CANDIDATES[@]}"; do + [[ "$existing" == "$normalized" ]] && return + done + fi + CODEOWNERS_SOURCE_CANDIDATES+=("$normalized") +} + +# Handle add derived source candidate behavior. +add_derived_source_candidate() { + local candidate="$1" + if [[ "$candidate" == external/* || "$candidate" == _main/external/* ]]; then + # Execroot/runfiles derived external paths belong to fetched dependencies, + # not repository-owned source files. Skip to avoid false owner attribution. + [[ "$DEBUG" == "1" ]] && dbg "codeowners: skip external source candidate '$candidate'" + return + fi + add_path_candidate "$candidate" +} + +# Handle strip workspace prefix behavior. +strip_workspace_prefix() { + local path_value="$1" + local root_value="$2" + [[ -z "$path_value" || -z "$root_value" ]] && return + local path_norm root_norm + path_norm=$(normalize_path_like "$path_value" || true) + root_norm=$(normalize_path_like "$root_value" || true) + [[ -z "$path_norm" || -z "$root_norm" ]] && return + if [[ "$path_norm" == "$root_norm" ]]; then + echo "" + return + fi + if [[ "$path_norm" == "$root_norm/"* ]]; then + echo "${path_norm#"$root_norm/"}" + fi +} + +# Handle build source candidates behavior. +build_source_candidates() { + local source_path="$1" + CODEOWNERS_SOURCE_CANDIDATES=() + local normalized_source stripped + normalized_source=$(normalize_path_like "$source_path" || true) + [[ -z "$normalized_source" ]] && return + + stripped=$(strip_workspace_prefix "$normalized_source" "$CODEOWNERS_CONTEXT_WORKSPACE") + [[ -n "$stripped" ]] && add_path_candidate "$stripped" + stripped=$(strip_workspace_prefix "$normalized_source" "$CODEOWNERS_WORKSPACE_ROOT") + [[ -n "$stripped" ]] && add_path_candidate "$stripped" + + if [[ "$normalized_source" =~ /execroot/[^/]+/_main/(.+)$ ]]; then + add_derived_source_candidate "${BASH_REMATCH[1]}" + fi + if [[ "$normalized_source" =~ /execroot/[^/]+/(.+)$ ]]; then + add_derived_source_candidate "${BASH_REMATCH[1]}" + fi + if [[ "$normalized_source" =~ \.runfiles/_main/(.+)$ ]]; then + add_derived_source_candidate "${BASH_REMATCH[1]}" + fi + if [[ "$normalized_source" =~ \.runfiles/[^/]+/(.+)$ ]]; then + add_derived_source_candidate "${BASH_REMATCH[1]}" + fi + # Keep only repository-relative fallback candidates. Absolute paths that are + # not under known repo roots can incorrectly inherit broad CODEOWNERS rules. + if [[ "$normalized_source" != /* && ! "$normalized_source" =~ ^[A-Za-z]:/ ]]; then + add_path_candidate "$normalized_source" + elif [[ "$DEBUG" == "1" ]]; then + dbg "codeowners: skip absolute source fallback candidate '$normalized_source'" + fi +} + +# Handle glob to regex behavior. +glob_to_regex() { + local pattern="$1" + local out="" + local i=0 + local plen="${#pattern}" + local ch nxt j class_ch class_body class_closed + while (( i < plen )); do + ch="${pattern:i:1}" + # Backslash escapes the next glob metacharacter literally. + if [[ "$ch" == "\\" ]]; then + if (( i + 1 < plen )); then + nxt="${pattern:i+1:1}" + case "$nxt" in + "."|"+"|"("|")"|"{"|"}"|"^"|"$"|"|"|"["|"]"|"*"|"?"|"\\") + if [[ "$nxt" == "\\" ]]; then + out="$out\\\\" + else + out="$out\\$nxt" + fi + ;; + *) + out="$out$nxt" + ;; + esac + i=$((i + 2)) + else + out="$out\\\\" + i=$((i + 1)) + fi + continue + fi + if [[ "$ch" == "*" ]] && (( i + 1 < plen )); then + nxt="${pattern:i+1:1}" + if [[ "$nxt" == "*" ]]; then + if (( i + 2 < plen )) && [[ "${pattern:i+2:1}" == "/" ]]; then + # CODEOWNERS follows gitignore-style globbing: **/ matches zero or more directories. + out="${out}(.*/)?" + i=$((i + 3)) + else + out="${out}.*" + i=$((i + 2)) + fi + continue + fi + fi + if [[ "$ch" == "[" ]]; then + # Preserve character class semantics (including "!"/"^" negation). + j=$((i + 1)) + class_body="" + class_closed=0 + if (( j < plen )) && [[ "${pattern:j:1}" == "!" ]]; then + class_body="^" + j=$((j + 1)) + elif (( j < plen )) && [[ "${pattern:j:1}" == "^" ]]; then + class_body="\\^" + j=$((j + 1)) + fi + if (( j < plen )) && [[ "${pattern:j:1}" == "]" ]]; then + class_body="$class_body\\]" + j=$((j + 1)) + fi + while (( j < plen )); do + class_ch="${pattern:j:1}" + if [[ "$class_ch" == "]" ]]; then + class_closed=1 + break + fi + case "$class_ch" in + "\\") + class_body="$class_body\\\\" + ;; + "^") + class_body="$class_body\\^" + ;; + "[") + class_body="$class_body\\[" + ;; + *) + class_body="$class_body$class_ch" + ;; + esac + j=$((j + 1)) + done + if (( class_closed == 1 )); then + out="${out}[$class_body]" + i=$((j + 1)) + continue + fi + out="${out}\\[" + i=$((i + 1)) + continue + fi + case "$ch" in + "*") + out="${out}[^/]*" + ;; + "?") + out="${out}[^/]" + ;; + "."|"+"|"("|")"|"{"|"}"|"^"|"$"|"|"|"\\") + out="${out}\\$ch" + ;; + "]") + out="${out}\\]" + ;; + *) + out="${out}$ch" + ;; + esac + i=$((i + 1)) + done + echo "$out" +} + +# Handle compile codeowners regex behavior. +compile_codeowners_regex() { + local pattern="$1" + local anchored=0 + local dir_only=0 + if [[ "$pattern" == /* ]]; then + anchored=1 + pattern="${pattern#/}" + fi + if [[ "$pattern" == */ ]]; then + dir_only=1 + pattern="${pattern%/}" + fi + [[ -z "$pattern" ]] && return 1 + + local has_slash=0 + [[ "$pattern" == */* ]] && has_slash=1 + local body + body=$(glob_to_regex "$pattern") + local prefix suffix regex + # Match semantics: + # - anchored or slash-containing patterns match from repo root + # - plain patterns match at any path segment boundary + if (( anchored == 1 || has_slash == 1 )); then + prefix="^" + else + prefix="(^|.*/)" + fi + if (( dir_only == 1 )); then + suffix="/.*$" + else + suffix="($|/.*)" + fi + regex="$prefix$body$suffix" + echo "$regex" + return 0 +} + +# Handle parse codeowners file behavior. +parse_codeowners_file() { + local file_path="$1" + local line pattern rest regex + local -a owner_tokens=() + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + line="${line#"${line%%[![:space:]]*}"}" + [[ -z "$line" || "${line:0:1}" == "#" ]] && continue + # Section headers may include spaces (for example "[Core Team] @org/team"). + # Detect them from the full raw line before splitting on whitespace. + if is_gitlab_section_header_line "$line"; then + continue + fi + split_codeowners_pattern_and_owners "$line" + pattern="$CODEOWNERS_SPLIT_PATTERN" + rest="$CODEOWNERS_SPLIT_OWNERS_RAW" + # Ignore GitLab section headers while preserving bracket-class glob rules. + # This keeps patterns like "[xy] @team/owners" valid CODEOWNERS entries. + if is_gitlab_section_header_pattern "$pattern"; then + continue + fi + # Strip comments in owner segments while preserving '#' inside owner tokens. + # Example: "@org/team#chat" stays intact, while " @org/team # note" strips note. + if [[ "$rest" == "#"* ]]; then + rest="" + elif [[ "$rest" == *[[:space:]]#* ]]; then + rest=$(printf '%s +' "$rest" | sed -E 's/[[:space:]]#.*$//') + fi + rest="${rest%"${rest##*[![:space:]]}"}" + [[ -z "$pattern" ]] && continue + owner_tokens=() + if [[ -n "$rest" ]]; then + read -r -a owner_tokens <<< "$rest" + fi + regex=$(compile_codeowners_regex "$pattern" || true) + [[ -z "$regex" ]] && continue + # Some character-class patterns can produce invalid POSIX ERE fragments + # (for example "[z-a]"). Validate here so malformed rules are skipped once + # at parse time instead of repeatedly triggering regex-eval errors later. + if ! codeowners_regex_is_valid "$regex"; then + [[ "$DEBUG" == "1" ]] && dbg "codeowners: skipping invalid regex '$regex' from pattern '$pattern'" + continue + fi + CODEOWNERS_RULE_REGEX+=("$regex") + if (( ${#owner_tokens[@]} == 0 )); then + CODEOWNERS_RULE_OWNERS+=("") + CODEOWNERS_RULE_HAS_OWNERS+=("0") + else + CODEOWNERS_RULE_OWNERS+=("$rest") + CODEOWNERS_RULE_HAS_OWNERS+=("1") + fi + if [[ "$DEBUG" == "1" ]]; then + local owners_dbg="" + if (( ${#owner_tokens[@]} > 0 )); then + owners_dbg="$rest" + fi + dbg "codeowners: parsed rule pattern='$pattern' regex='$regex' owners='$owners_dbg'" + fi + done < "$file_path" +} + +# Handle is gitlab section header pattern behavior. +is_gitlab_section_header_pattern() { + local pattern="$1" + [[ "$pattern" =~ ^\[[^][]+\]$ ]] || return 1 + local inner="${pattern:1:${#pattern}-2}" + # GitLab section headers can include whitespace (for example [Core Team]). + if [[ "$inner" == *[[:space:]]* ]]; then + return 0 + fi + # Heuristic to avoid class-only glob false positives: + # keep range-like and short bracket classes (for example [xy], [A-Z]). + if [[ "$inner" == *"-"* || "$inner" == *"!"* || "$inner" == *"^"* || "$inner" == *"\\"* ]]; then + return 1 + fi + # Preserve all-uppercase/digit class sets such as [ABCD] and [A1B2C3]. + if [[ "$inner" =~ ^[A-Z0-9]+$ ]]; then + return 1 + fi + # Preserve short alnum bracket classes (for example [xy], [ABC], [Abc]). + if (( ${#inner} <= 3 )) && [[ "$inner" =~ ^[A-Za-z0-9]+$ ]]; then + return 1 + fi + # Preserve plain lowercase/digit class sets such as [abc] and [a1b2]. + if [[ "$inner" =~ ^[a-z0-9]+$ ]]; then + return 1 + fi + return 0 +} + +# Handle is gitlab section header line behavior. +is_gitlab_section_header_line() { + local line="$1" + if [[ "$line" =~ ^(\[[^][]+\])([[:space:]]+.*)?$ ]]; then + is_gitlab_section_header_pattern "${BASH_REMATCH[1]}" + return $? + fi + return 1 +} + +# Handle codeowners regex is valid behavior. +codeowners_regex_is_valid() { + local regex="$1" + local status=0 + # Run the probe inside `if` so set -e does not abort on a normal no-match. + if ( [[ "" =~ $regex ]] ) 2>/dev/null; then + status=0 + else + status=$? + fi + # Bash returns: + # 0 => matched + # 1 => valid regex, no match + # 2 => invalid regex syntax + if (( status == 0 || status == 1 )); then + return 0 + fi + return 1 +} + +# Handle split codeowners pattern and owners behavior. +split_codeowners_pattern_and_owners() { + local line="$1" + local pattern="" + local rest="" + local i ch escaped=0 + local line_len="${#line}" + for ((i = 0; i < line_len; i++)); do + ch="${line:i:1}" + if (( escaped == 1 )); then + pattern="$pattern$ch" + escaped=0 + continue + fi + if [[ "$ch" == "\\" ]]; then + pattern="$pattern$ch" + escaped=1 + continue + fi + # Split on the first unescaped whitespace character. + # We intentionally use a character-class check (instead of only " " and + # tab) to match CODEOWNERS behavior for any ASCII whitespace separator. + if [[ "$ch" =~ [[:space:]] ]]; then + rest="${line:i}" + rest="${rest#"${rest%%[![:space:]]*}"}" + CODEOWNERS_SPLIT_PATTERN="$pattern" + CODEOWNERS_SPLIT_OWNERS_RAW="$rest" + return 0 + fi + pattern="$pattern$ch" + done + CODEOWNERS_SPLIT_PATTERN="$pattern" + CODEOWNERS_SPLIT_OWNERS_RAW="" + return 0 +} + +# Handle init codeowners behavior. +init_codeowners() { + (( CODEOWNERS_INITIALIZED == 1 )) && return + CODEOWNERS_INITIALIZED=1 + if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" ]]; then + CODEOWNERS_WORKSPACE_ROOT="$BUILD_WORKSPACE_DIRECTORY" + elif [[ -n "${TESTLOGS_DIR:-}" && "$TESTLOGS_DIR" == */bazel-testlogs* ]]; then + CODEOWNERS_WORKSPACE_ROOT="${TESTLOGS_DIR%%/bazel-testlogs*}" + else + CODEOWNERS_WORKSPACE_ROOT="$(pwd)" + fi + [[ -z "$CODEOWNERS_WORKSPACE_ROOT" ]] && CODEOWNERS_WORKSPACE_ROOT="$(pwd)" + CODEOWNERS_CONTEXT_WORKSPACE="" + if (( JQ_AVAILABLE == 1 )) && [[ -n "$CONTEXT_JSON" && -f "$CONTEXT_JSON" ]]; then + CODEOWNERS_CONTEXT_WORKSPACE=$(jq -r '."ci.workspace_path" // empty' "$CONTEXT_JSON" 2>/dev/null || true) + fi + + local explicit_codeowners="${DD_TEST_OPTIMIZATION_CODEOWNERS_FILE:-}" + if [[ -n "$explicit_codeowners" ]]; then + [[ "$DEBUG" == "1" ]] && dbg "codeowners: explicit path candidate '$explicit_codeowners'" + if [[ -f "$explicit_codeowners" && -r "$explicit_codeowners" ]]; then + CODEOWNERS_FILE="$explicit_codeowners" + dbg "codeowners: using explicit CODEOWNERS file '$CODEOWNERS_FILE'" + else + dbg "codeowners: DD_TEST_OPTIMIZATION_CODEOWNERS_FILE is set but not readable: '$explicit_codeowners' (falling back to discovery)" + fi + fi + + local script_dir + script_dir=$(cd "$(dirname "$0")" && pwd -P) + local -a candidates=() + if [[ -z "$CODEOWNERS_FILE" ]]; then + # Lookup order is intentional and mirrored in PowerShell implementation. + # We prefer `ci.workspace_path` when present, then workspace-derived paths, + # then process cwd, then script directory fallback. + if [[ -n "$CODEOWNERS_CONTEXT_WORKSPACE" ]]; then + candidates+=( + "$CODEOWNERS_CONTEXT_WORKSPACE/CODEOWNERS" + "$CODEOWNERS_CONTEXT_WORKSPACE/.github/CODEOWNERS" + "$CODEOWNERS_CONTEXT_WORKSPACE/.gitlab/CODEOWNERS" + "$CODEOWNERS_CONTEXT_WORKSPACE/docs/CODEOWNERS" + "$CODEOWNERS_CONTEXT_WORKSPACE/.docs/CODEOWNERS" + ) + fi + if [[ -n "$CODEOWNERS_WORKSPACE_ROOT" ]]; then + candidates+=( + "$CODEOWNERS_WORKSPACE_ROOT/CODEOWNERS" + "$CODEOWNERS_WORKSPACE_ROOT/.github/CODEOWNERS" + "$CODEOWNERS_WORKSPACE_ROOT/.gitlab/CODEOWNERS" + "$CODEOWNERS_WORKSPACE_ROOT/docs/CODEOWNERS" + "$CODEOWNERS_WORKSPACE_ROOT/.docs/CODEOWNERS" + ) + fi + candidates+=( + "./CODEOWNERS" + "$script_dir/CODEOWNERS" + ) + + local candidate + for candidate in "${candidates[@]}"; do + [[ -z "$candidate" ]] && continue + [[ "$DEBUG" == "1" && -f "$candidate" ]] && dbg "codeowners: discovery candidate hit '$candidate'" + if [[ -f "$candidate" && -r "$candidate" ]]; then + CODEOWNERS_FILE="$candidate" + break + fi + done + fi + + if [[ -z "$CODEOWNERS_FILE" ]]; then + dbg "codeowners: no CODEOWNERS file found (workspace='$CODEOWNERS_WORKSPACE_ROOT')" + return + fi + + parse_codeowners_file "$CODEOWNERS_FILE" + if (( ${#CODEOWNERS_RULE_REGEX[@]} > 0 )); then + CODEOWNERS_ENABLED=1 + dbg "codeowners: using '$CODEOWNERS_FILE' with ${#CODEOWNERS_RULE_REGEX[@]} rule(s)" + else + dbg "codeowners: file '$CODEOWNERS_FILE' had no usable rules" + fi +} + +# Handle dedupe owners behavior. +dedupe_owners() { + local owners_line="$1" + local -a in_tokens=() + local -a out_tokens=() + local token existing seen + read -r -a in_tokens <<< "$owners_line" + for token in "${in_tokens[@]}"; do + [[ -z "$token" ]] && continue + seen=0 + if (( ${#out_tokens[@]} > 0 )); then + for existing in "${out_tokens[@]}"; do + if [[ "$existing" == "$token" ]]; then + seen=1 + break + fi + done + fi + (( seen == 0 )) && out_tokens+=("$token") + done + if (( ${#out_tokens[@]} > 0 )); then + printf '%s +' "${out_tokens[@]}" + fi +} + +# Handle owners line to json behavior. +owners_line_to_json() { + local owners_line="$1" + local deduped + deduped=$(dedupe_owners "$owners_line" | jq -R . | jq -s -c '.' 2>/dev/null || true) + if [[ "$deduped" == "[]" ]]; then + echo "" + else + echo "$deduped" + fi +} + +# Handle match codeowners owners line behavior. +match_codeowners_owners_line() { + local candidate="$1" + local idx regex owners_line rule_has_owners matched="$CODEOWNERS_MATCH_NONE" + # Last matching CODEOWNERS rule wins. + for ((idx = 0; idx < ${#CODEOWNERS_RULE_REGEX[@]}; idx++)); do + regex="${CODEOWNERS_RULE_REGEX[$idx]}" + owners_line="${CODEOWNERS_RULE_OWNERS[$idx]}" + rule_has_owners="${CODEOWNERS_RULE_HAS_OWNERS[$idx]}" + if [[ "$candidate" =~ $regex ]]; then + if [[ "$rule_has_owners" == "1" ]]; then + matched="$owners_line" + else + matched="$CODEOWNERS_MATCH_EMPTY" + fi + fi + done + echo "$matched" +} + +# Handle resolve codeowners json for source behavior. +resolve_codeowners_json_for_source() { + local source_path="$1" + build_source_candidates "$source_path" + local candidate owners_line owners_json + # Candidate order matters: prefer repo-relative derivations before broader + # fallbacks so ownership reflects the most likely source path. + for candidate in "${CODEOWNERS_SOURCE_CANDIDATES[@]}"; do + owners_line=$(match_codeowners_owners_line "$candidate") + if [[ "$DEBUG" == "1" ]]; then + if [[ "$owners_line" == "$CODEOWNERS_MATCH_NONE" ]]; then + dbg "codeowners: candidate='$candidate' owners=''" + elif [[ "$owners_line" == "$CODEOWNERS_MATCH_EMPTY" ]]; then + dbg "codeowners: candidate='$candidate' owners=''" + else + dbg "codeowners: candidate='$candidate' owners='$owners_line'" + fi + fi + if [[ "$owners_line" == "$CODEOWNERS_MATCH_NONE" ]]; then + continue + fi + if [[ "$owners_line" == "$CODEOWNERS_MATCH_EMPTY" ]]; then + # Explicit "no owners" rule matched; treat as no tag. + # This preserves CODEOWNERS semantics where later empty-owner rules + # intentionally clear ownership for matching paths. + echo "" + return + fi + if [[ -n "$owners_line" ]]; then + owners_json=$(owners_line_to_json "$owners_line") + if [[ -n "$owners_json" ]]; then + echo "$owners_json" + return + fi + fi + done + echo "" +} + +# Handle inject codeowners tags behavior. +inject_codeowners_tags() { + local payload_file="$1" + init_codeowners + (( CODEOWNERS_ENABLED == 1 )) || return 0 + + local events_len idx event_type has_existing source_path owners_json tmp_payload + # Skip gracefully on malformed payload shapes; uploader remains best-effort. + events_len=$(jq '.events | if type=="array" then length else 0 end' "$payload_file" 2>/dev/null || echo 0) + if ! [[ "$events_len" =~ ^[0-9]+$ ]]; then + return 0 + fi + + for ((idx = 0; idx < events_len; idx++)); do + event_type=$(jq -r --argjson idx "$idx" '.events[$idx].type // ""' "$payload_file" 2>/dev/null || true) + # Spans are intentionally not enriched with CODEOWNERS metadata. + [[ "$event_type" == "span" ]] && continue + ((++CO_EVENTS_SCANNED)) + + has_existing=$(jq -r --argjson idx "$idx" 'if (.events[$idx].content.meta | type) == "object" and (.events[$idx].content.meta | has("test.codeowners")) then "1" else "0" end' "$payload_file" 2>/dev/null || echo "0") + if [[ "$has_existing" == "1" ]]; then + ((++CO_EVENTS_SKIPPED_EXISTING)) + [[ "$DEBUG" == "1" ]] && dbg "codeowners: skip existing tag at event[$idx]" + continue + fi + + source_path=$(jq -r --argjson idx "$idx" '.events[$idx].content.meta["test.source.file"] // .events[$idx].content.meta["test.source.path"] // .events[$idx].content.meta["source.file"] // .events[$idx].content.meta["source.path"] // .events[$idx].content.source.file // .events[$idx].content.source.path // ""' "$payload_file" 2>/dev/null || true) + if [[ -z "$source_path" ]]; then + ((++CO_EVENTS_SKIPPED_MISSING_SOURCE)) + [[ "$DEBUG" == "1" ]] && dbg "codeowners: skip missing source at event[$idx]" + continue + fi + + owners_json=$(resolve_codeowners_json_for_source "$source_path") + if [[ -z "$owners_json" ]]; then + ((++CO_EVENTS_SKIPPED_UNMATCHED)) + [[ "$DEBUG" == "1" ]] && dbg "codeowners: skip unmatched source '$source_path' at event[$idx]" + continue + fi + + tmp_payload=$(mktemp "$TMP_PAYLOAD_DIR/codeowners_payload.XXXXXX" 2>/dev/null || true) + if [[ -z "$tmp_payload" ]]; then + ((++CO_EVENTS_SKIPPED_ERRORS)) + [[ "$DEBUG" == "1" ]] && dbg "codeowners: skip internal error creating temp payload at event[$idx]" + continue + fi + if jq --arg owners "$owners_json" --argjson idx "$idx" ' + .events[$idx].content = (.events[$idx].content // {}) + | .events[$idx].content.meta = ((.events[$idx].content.meta // {}) | .["test.codeowners"] = $owners) + ' "$payload_file" > "$tmp_payload"; then + # Atomic replacement prevents partially-written payload files. + mv "$tmp_payload" "$payload_file" + ((++CO_EVENTS_ENRICHED)) + [[ "$DEBUG" == "1" ]] && dbg "codeowners: assigned owners '$owners_json' at event[$idx]" + else + rm -f "$tmp_payload" 2>/dev/null || true + ((++CO_EVENTS_SKIPPED_ERRORS)) + [[ "$DEBUG" == "1" ]] && dbg "codeowners: skip jq update failure at event[$idx]" + fi + done + + if [[ "$DEBUG" == "1" ]]; then + dbg "codeowners: scanned=$CO_EVENTS_SCANNED enriched=$CO_EVENTS_ENRICHED skipped_existing=$CO_EVENTS_SKIPPED_EXISTING skipped_missing_source=$CO_EVENTS_SKIPPED_MISSING_SOURCE skipped_unmatched=$CO_EVENTS_SKIPPED_UNMATCHED skipped_errors=$CO_EVENTS_SKIPPED_ERRORS" + fi +} + +# Build common Datadog headers, optionally deriving values from payload metadata["*"]. +build_common_headers() { + local payload_file="${1:-}" + local lang="$HEADER_LANG_DEFAULT" + local lang_version="$HEADER_LANG_VERSION_DEFAULT" + local lang_interpreter="$HEADER_LANG_INTERPRETER_DEFAULT" + local tracer_version="$HEADER_TRACER_VERSION_DEFAULT" + + if (( JQ_AVAILABLE == 1 )) && [[ -n "$payload_file" && -f "$payload_file" ]]; then + local meta_values meta_lang meta_tracer meta_lang_version meta_lang_interpreter + meta_values=$(jq -r ' + [ + .metadata["*"]["language"] // "", + .metadata["*"]["library_version"] // "", + (.metadata["*"]["language_version"] // .metadata["*"]["runtime_version"] // ""), + (.metadata["*"]["language_interpreter"] // .metadata["*"]["runtime_name"] // "") + ] | @tsv + ' "$payload_file" 2>/dev/null || true) + if [[ -n "$meta_values" ]]; then + IFS=$' ' read -r meta_lang meta_tracer meta_lang_version meta_lang_interpreter <<< "$meta_values" + [[ -n "$meta_lang" ]] && lang="$meta_lang" + [[ -n "$meta_tracer" ]] && tracer_version="$meta_tracer" + [[ -n "$meta_lang_version" ]] && lang_version="$meta_lang_version" + [[ -n "$meta_lang_interpreter" ]] && lang_interpreter="$meta_lang_interpreter" + fi + fi + + COMMON_HDRS=( + -H "Datadog-Meta-Lang: $lang" + -H "Datadog-Meta-Lang-Version: $lang_version" + -H "Datadog-Meta-Lang-Interpreter: $lang_interpreter" + -H "Datadog-Meta-Tracer-Version: $tracer_version" + -H "Accept: application/json" + ) +} + +# Execute curl in agentless mode while sending DD-API-KEY via stdin (`-H @-`). +# This avoids exposing raw credentials in process arguments. +curl_agentless() { + if [[ -z "${DD_API_KEY:-}" ]]; then + return 2 + fi + printf 'DD-API-KEY: %s +' "$DD_API_KEY" | curl "$@" -H @- +} + +# Optional check: verify fetch-time API key fingerprint matches uploader API key. +API_KEY_FINGERPRINT="" +if (( JQ_AVAILABLE == 1 )) && [[ -n "$CONTEXT_JSON" && -f "$CONTEXT_JSON" ]]; then + API_KEY_FINGERPRINT=$(jq -r '."topt.api_key_fingerprint" // empty' "$CONTEXT_JSON" 2>/dev/null || true) +fi +if [[ -n "$API_KEY_FINGERPRINT" ]]; then + if (( AGENTLESS == 1 )); then + # Compare fetch-time and upload-time credentials without exposing raw keys. + local_fp=$(fnv1a_32 "$DD_API_KEY") + if [[ -n "$local_fp" && "$local_fp" != "$API_KEY_FINGERPRINT" ]]; then + log "warning: DD_API_KEY mismatch between fetch and uploader" + else + dbg "DD_API_KEY fingerprint match" + fi + else + # EVP mode does not require DD_API_KEY for upload requests. + log "warning: DD_API_KEY fingerprint present but uploader running in EVP mode; check skipped" + fi +elif [[ -n "$CONTEXT_JSON" && -f "$CONTEXT_JSON" && "$JQ_AVAILABLE" != "1" ]]; then + dbg "api key fingerprint check skipped: jq not available" +fi + +# Handle enrich with context behavior. +enrich_with_context() { + local infile="$1"; local tmpfile="$2" + dbg "enrich_with_context: infile='$infile' outfile='$tmpfile' ctx='${CONTEXT_JSON:-}' jq=$JQ_AVAILABLE" + if (( JQ_AVAILABLE == 0 )); then + # No jq means no structural merge; forward original payload unchanged. + cp "$infile" "$tmpfile" + return 0 + fi + local ctx_file="$CONTEXT_JSON" + local cleanup_ctx="" + if [[ -z "$ctx_file" || ! -f "$ctx_file" ]]; then + # Missing context is non-fatal: use empty object so enrichment still + # normalizes metadata shape without injecting context tags. + ctx_file="$(mktemp "$TMP_PAYLOAD_DIR/context.XXXXXX" 2>/dev/null || true)" + if [[ -z "$ctx_file" ]]; then + cp "$infile" "$tmpfile" + return 0 + fi + echo '{}' > "$ctx_file" + cleanup_ctx=1 + fi + jq --slurpfile ctx "$ctx_file" --arg runtime_id "$RUNTIME_ID" --arg rules_version "$RULES_VERSION" --arg language_fallback "bazel" ' + def ctx_val($k): $ctx[0][$k]; + def ctx_str($k): (ctx_val($k) | if type=="string" and length>0 then . else null end); + def ctx_runtime_id: (ctx_str("runtime-id") // ctx_str("runtime.id") // ctx_str("runtime_id")); + def ctx_language: (ctx_str("language") // ctx_str("runtime.name") // ctx_str("runtime_name")); + def ctx_env: ctx_str("env"); + def ctx_filtered: ($ctx[0] | with_entries(select(.key != "topt.api_key_fingerprint"))); + def meta_star: (.metadata["*"] | if type=="object" then . else {} end); + def runtime_id: (meta_star["runtime-id"] // ctx_runtime_id // $runtime_id); + def language: (meta_star["language"] // ctx_language // $language_fallback); + def library_version: (meta_star["library_version"] // $rules_version); + def env: (meta_star["env"] // ctx_env); + .metadata = (.metadata // {}) + | .metadata["*"] = ( + { "runtime-id": runtime_id, "language": language, "library_version": library_version } + + (if (env|type) == "string" then { "env": env } else {} end) + ) + | .metadata = ( + { "*": .metadata["*"] } + + (if (.metadata["test"]? != null) then { "test": .metadata["test"] } else {} end) + + (if (.metadata["test_suite_end"]? != null) then { "test_suite_end": .metadata["test_suite_end"] } else {} end) + + (if (.metadata["test_module_end"]? != null) then { "test_module_end": .metadata["test_module_end"] } else {} end) + + (if (.metadata["test_session_end"]? != null) then { "test_session_end": .metadata["test_session_end"] } else {} end) + ) + | (if .events then + .events |= map( + if (.type? == "span") then . + else + ( + .content = (.content // {}) + | .content.meta = (if (.content.meta|type) == "object" then .content.meta else {} end) + | .content.metrics = (if (.content.metrics|type) == "object" then .content.metrics else {} end) + | reduce (ctx_filtered | to_entries[]) as $e (.; + if ($e.value|type) == "number" then + .content.metrics[$e.key] = $e.value + elif ($e.value|type) == "string" then + .content.meta[$e.key] = $e.value + else + .content.meta[$e.key] = ($e.value|tostring) + end + ) + ) + end + ) + else . + end) + ' "$infile" > "$tmpfile" + # CODEOWNERS enrichment is applied after metadata/context merge so source-path + # detection can leverage normalized event structure. + inject_codeowners_tags "$tmpfile" + if [[ -n "$cleanup_ctx" ]]; then + rm -f "$ctx_file" 2>/dev/null || true + fi +} + +# Emit basic startTime statistics (ms) for debugging when jq is available. +log_start_time_stats() { + local file="$1" + if (( JQ_AVAILABLE == 0 )); then + dbg "startTime stats skipped: jq not available" + return 0 + fi + local times + # Prefer startTime; fall back to start if startTime is absent + times=$(jq -r '.. | objects | (.startTime? // .start?) | select(type=="number")' "$file" 2>/dev/null || true) + if [[ -z "$times" ]]; then + dbg "startTime stats: no startTime fields found in $file" + return 0 + fi + local min max + read min max < <(echo "$times" | awk 'NR==1{min=$1;max=$1} {if($1max)max=$1} END{print min,max}') + local now_ms + now_ms=$(( $(date +%s) * 1000 )) + dbg "startTime/ms range for $file: min=$min max=$max now=$now_ms" +} + +# Check if file matches prefix filter (when enabled) +matches_filter() { + local file="$1" + local expected_prefix="$2" + if [[ "$FILTER_PREFIX" == "1" ]]; then + local basename + basename=$(basename "$file") + [[ "$basename" == "$expected_prefix"* ]] + else + return 0 # No filtering, accept all + fi +} + +# Delete file unless KEEP_PAYLOADS is set +cleanup_file() { + local file="$1" + if [[ "$KEEP_PAYLOADS" != "1" ]]; then + # Some runfiles can be read-only; best-effort cleanup keeps uploads resilient. + if ! rm -f "$file" 2>/dev/null; then + chmod u+w "$file" 2>/dev/null || true + rm -f "$file" 2>/dev/null || true + fi + else + dbg "keeping payload (KEEP_PAYLOADS=1): $file" + fi +} + +# Handle validate payload behavior. +validate_payload() { + local file="$1" + if [[ -z "$SCHEMA_JSON" || ! -f "$SCHEMA_JSON" ]]; then + # Validation is best-effort and must never block uploads by default. + dbg "schema validation skipped: schema not available" + return 0 + fi + if [[ -z "$SCHEMA_VALIDATOR" || ! -f "$SCHEMA_VALIDATOR" ]]; then + dbg "schema validation skipped: validator not available" + return 0 + fi + if ! command -v python3 >/dev/null 2>&1; then + dbg "schema validation skipped: python3 not available" + return 0 + fi + dbg "schema validate: python3 $SCHEMA_VALIDATOR $SCHEMA_JSON $file" + if ! python3 "$SCHEMA_VALIDATOR" "$SCHEMA_JSON" "$file"; then + # Keep warning-only behavior so schema drift does not drop payloads. + log "warning: schema validation failed for payload: $file" + fi + return 0 +} + +# Track upload failures globally +UPLOAD_FAILURES=0 + +# Handle upload single test behavior. +upload_single_test() { + local file="$1" + local body resp payload_file gz http rc + # Use a temp file to avoid collisions when multiple uploads run in parallel. + body="$(mktemp "$TMP_PAYLOAD_DIR/test_payload.XXXXXX" 2>/dev/null || true)" + if [[ -z "$body" ]]; then + dbg "upload_single_test: failed to create temp file" + return 1 + fi + enrich_with_context "$file" "$body" + validate_payload "$body" + build_common_headers "$body" + dbg "upload_single_test: posting '$file' (body '$body')" + if [[ "$DEBUG" == "1" ]]; then + local gzip_note="" + if [[ "$GZIP_PAYLOADS" == "1" ]]; then + gzip_note="; Content-Encoding=gzip" + fi + echo "[dd-uploader][dbg] payload content (enriched) for '$file':" >&2 + cat "$body" >&2 + echo "" >&2 + log_start_time_stats "$body" + dbg "headers: Content-Type=application/json${gzip_note}" + fi + + payload_file="$body" + gz="" + if [[ "$GZIP_PAYLOADS" == "1" ]]; then + # Compress enriched payload, but gracefully fall back to plain JSON if + # gzip is unavailable/fails on the host. + gz="$body.gz" + if gzip -c "$body" > "$gz"; then + payload_file="$gz" + else + log "warning: gzip failed; sending uncompressed payload" + gz="" + fi + fi + + resp="$(mktemp "$TMP_PAYLOAD_DIR/test_resp.XXXXXX" 2>/dev/null || true)" + if [[ -z "$resp" ]]; then + dbg "upload_single_test: failed to create response temp file" + rm -f "$body" "$gz" 2>/dev/null || true + return 1 + fi + local ce_hdr=() + if [[ "$payload_file" != "$body" ]]; then + # Signal compressed body only when gzip output is actually used. + ce_hdr=(-H "Content-Encoding: gzip") + fi + if [[ "$DEBUG" == "1" ]]; then + dbg "request: POST $TEST_URL" + dbg_headers "common" "${COMMON_HDRS[@]}" + if (( AGENTLESS == 0 )); then + dbg_headers "evp" "${TEST_EVP[@]}" + fi + if [[ "$payload_file" != "$body" ]]; then + dbg "header[content-encoding]: Content-Encoding: gzip" + fi + fi + if (( AGENTLESS == 1 )); then + http=$(curl_agentless -f -sS --connect-timeout 10 --max-time 60 "${CURL_RETRY_FLAGS[@]}" \ + -X POST "${TEST_URL}" "${COMMON_HDRS[@]}" "${ce_hdr[@]+${ce_hdr[@]}}" -H "Content-Type: application/json" --data-binary @"${payload_file}" -o "$resp" -w "%{http_code}") + else + http=$(curl -f -sS --connect-timeout 10 --max-time 60 "${CURL_RETRY_FLAGS[@]}" \ + -X POST "${TEST_URL}" "${COMMON_HDRS[@]}" "${TEST_EVP[@]}" "${ce_hdr[@]+${ce_hdr[@]}}" -H "Content-Type: application/json" --data-binary @"${payload_file}" -o "$resp" -w "%{http_code}") + fi + rc=$? + http="${http:-000}" + if [[ "$DEBUG" == "1" || $rc -ne 0 || "$http" -lt 200 || "$http" -ge 300 ]]; then + dbg "upload_single_test: HTTP $http (rc=$rc)" + if [[ -s "$resp" ]]; then + dbg "upload_single_test response: $(head -c 2000 "$resp")" + fi + fi + rm -f "$resp" "$body" "$gz" 2>/dev/null || true + # Cleanup happens before return to avoid temp-file buildup on retries/runs. + if [[ $rc -ne 0 || "$http" -lt 200 || "$http" -ge 300 ]]; then + return 1 + fi + return 0 +} + +# Handle upload single coverage behavior. +upload_single_coverage() { + local file="$1" + # Create event.json for multipart + local eventjson resp http rc + # Use a temp file for multipart metadata to avoid leaking into runfiles. + eventjson="$(mktemp "$TMP_PAYLOAD_DIR/coverage_event.XXXXXX" 2>/dev/null || true)" + if [[ -z "$eventjson" ]]; then + dbg "upload_single_coverage: failed to create temp file" + return 1 + fi + echo '{"dummy":true}' > "$eventjson" + build_common_headers "" + dbg "upload_single_coverage: posting '$file'" + resp="$(mktemp "$TMP_PAYLOAD_DIR/coverage_resp.XXXXXX" 2>/dev/null || true)" + if [[ -z "$resp" ]]; then + dbg "upload_single_coverage: failed to create response temp file" + rm -f "$eventjson" 2>/dev/null || true + return 1 + fi + if [[ "$DEBUG" == "1" ]]; then + dbg "request: POST $COV_URL" + dbg_headers "common" "${COMMON_HDRS[@]}" + if (( AGENTLESS == 0 )); then + dbg_headers "evp" "${COV_EVP[@]}" + fi + dbg "headers: multipart/form-data (event + coveragex)" + fi + if (( AGENTLESS == 1 )); then + http=$(curl_agentless -f -sS --connect-timeout 10 --max-time 60 "${CURL_RETRY_FLAGS[@]}" \ + -X POST "${COV_URL}" "${COMMON_HDRS[@]}" \ + -F "event=@${eventjson};type=application/json;filename=fileevent.json" \ + -F "coveragex=@${file};type=application/json;filename=filecoveragex.json" -o "$resp" -w "%{http_code}") + else + http=$(curl -f -sS --connect-timeout 10 --max-time 60 "${CURL_RETRY_FLAGS[@]}" \ + -X POST "${COV_URL}" "${COMMON_HDRS[@]}" "${COV_EVP[@]}" \ + -F "event=@${eventjson};type=application/json;filename=fileevent.json" \ + -F "coveragex=@${file};type=application/json;filename=filecoveragex.json" -o "$resp" -w "%{http_code}") + fi + rc=$? + http="${http:-000}" + if [[ "$DEBUG" == "1" || $rc -ne 0 || "$http" -lt 200 || "$http" -ge 300 ]]; then + dbg "upload_single_coverage: HTTP $http (rc=$rc)" + if [[ -s "$resp" ]]; then + dbg "upload_single_coverage response: $(head -c 2000 "$resp")" + fi + fi + rm -f "$resp" "$eventjson" 2>/dev/null || true + if [[ $rc -ne 0 || "$http" -lt 200 || "$http" -ge 300 ]]; then + return 1 + fi + return 0 +} + +# Handle upload all tests behavior. +upload_all_tests() { + local total=0 + local failed=0 + local skipped=0 + # Iterate the cached test.outputs list to avoid rescanning the filesystem. + while IFS= read -r outputs_dir; do + [[ -z "$outputs_dir" ]] && continue + local tests_dir="$outputs_dir/payloads/tests" + [[ -d "$tests_dir" ]] || continue + + for f in "$tests_dir"/*.json; do + [[ -f "$f" ]] || continue + # Skip files not matching prefix filter (when enabled) + if ! matches_filter "$f" "span_events_"; then + dbg "skipping (prefix filter): $f" + ((++skipped)) + continue + fi + if upload_single_test "$f"; then + log "uploaded test payload: $f" + cleanup_file "$f" + ((++total)) + else + # Keep uploading subsequent files to maximize successful delivery + # even when one payload is malformed or temporarily rejected. + log "warning: failed to upload $f" + ((++failed)) + ((++UPLOAD_FAILURES)) + fi + done + done < <(echo "$TEST_OUTPUTS_CACHE") + log "uploaded $total test payloads" + if (( failed > 0 )); then + log "warning: $failed test payloads failed to upload" + fi + if (( skipped > 0 )); then + dbg "skipped $skipped files (prefix filter)" + fi +} + +# Handle upload all coverage behavior. +upload_all_coverage() { + local total=0 + local failed=0 + local skipped=0 + # Iterate the cached test.outputs list to avoid rescanning the filesystem. + while IFS= read -r outputs_dir; do + [[ -z "$outputs_dir" ]] && continue + local cov_dir="$outputs_dir/payloads/coverage" + [[ -d "$cov_dir" ]] || continue + + for f in "$cov_dir"/*.json; do + [[ -f "$f" ]] || continue + # Skip files not matching prefix filter (when enabled) + if ! matches_filter "$f" "coverage_"; then + dbg "skipping (prefix filter): $f" + ((++skipped)) + continue + fi + if upload_single_coverage "$f"; then + log "uploaded coverage payload: $f" + cleanup_file "$f" + ((++total)) + else + # Coverage failures are tracked but non-fatal per-file; final + # exit code reflects aggregate failure count after both passes. + log "warning: failed to upload $f" + ((++failed)) + ((++UPLOAD_FAILURES)) + fi + done + done < <(echo "$TEST_OUTPUTS_CACHE") + log "uploaded $total coverage payloads" + if (( failed > 0 )); then + log "warning: $failed coverage payloads failed to upload" + fi + if (( skipped > 0 )); then + dbg "skipped $skipped files (prefix filter)" + fi +} + +upload_all_tests +upload_all_coverage + +# Exit with appropriate code based on upload results +if (( UPLOAD_FAILURES > 0 )); then + # Non-zero signals partial/total upload failure to CI orchestration. + log "done with $UPLOAD_FAILURES upload failures" + exit 1 +else + # Zero means either complete success or intentional no-op path above. + log "done" + exit 0 +fi diff --git a/tools/core/uploader_bash_template.bzl b/tools/core/uploader_bash_template.bzl index 14894f95..6771a2df 100644 --- a/tools/core/uploader_bash_template.bzl +++ b/tools/core/uploader_bash_template.bzl @@ -1,2028 +1,3 @@ -"""Bash runtime template for dd_payload_uploader.""" +"""Metadata for the standalone Bash uploader runtime template.""" -UPLOADER_BASH_TEMPLATE = """ -#!/usr/bin/env bash -set -euo pipefail - -# NOTE: This is a template file. Placeholders like {{quiescent_sec}} are replaced -# by Starlark during rule execution. Double braces {{ and }} are literal braces -# (escaped for Python .format() compatibility). - -# Logging functions (defined first so other functions can use them) -# DEBUG is set later, so we use a function that checks the variable at runtime -log() {{ echo "[dd-uploader] $1"; }} -DEBUG_BOOTSTRAP=$(echo "${{DD_TEST_OPTIMIZATION_DEBUG:-0}}" | tr '[:upper:]' '[:lower:]') -dbg() {{ - local dbg_val="${{DEBUG:-$DEBUG_BOOTSTRAP}}" - dbg_val=$(echo "$dbg_val" | tr '[:upper:]' '[:lower:]') - if [[ "$dbg_val" == "1" || "$dbg_val" == "true" || "$dbg_val" == "yes" ]]; then - echo "[dd-uploader][dbg] $1" >&2 - fi -}} -dbg "startup runfiles env: RUNFILES_DIR='${{RUNFILES_DIR:-}}' RUNFILES_MANIFEST_FILE='${{RUNFILES_MANIFEST_FILE:-}}' script='$0'" - -trim_ascii_whitespace() {{ - local value="$1" - value="${{value#"${{value%%[!$' \t\r\n']*}}"}}" - value="${{value%"${{value##*[!$' \t\r\n']}}"}}" - printf '%s\n' "$value" -}} - -normalize_dd_site_or_fail() {{ - local raw="$1" - local site - site=$(trim_ascii_whitespace "$raw") - if [[ -z "$site" ]]; then - echo "datadoghq.com" - return 0 - fi - - # Keep compatibility with legacy DD_SITE input shapes. - if [[ "$site" == *"://"* ]]; then - site="${{site#*://}}" - fi - site="${{site%%/*}}" - site="${{site%%\\?*}}" - site="${{site%%#*}}" - if [[ "$site" == app.* ]]; then site="${{site#app.}}"; fi - if [[ "$site" == api.* ]]; then site="${{site#api.}}"; fi - site=$(echo "$site" | tr '[:upper:]' '[:lower:]') - site=$(trim_ascii_whitespace "$site") - - if [[ -z "$site" ]]; then - log "error: DD_SITE resolved to an empty hostname (input: '$raw')" - return 1 - fi - if [[ "$site" == *"@"* ]]; then - log "error: DD_SITE must not include credentials/userinfo: '$raw'" - return 1 - fi - if [[ "$site" == *":"* ]]; then - log "error: DD_SITE must be a hostname without an explicit port: '$raw'" - return 1 - fi - if [[ "$site" == .* || "$site" == *. || "$site" == *..* ]]; then - log "error: DD_SITE must be a valid hostname: '$raw'" - return 1 - fi - if [[ ! "$site" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?([.][a-z0-9]([a-z0-9-]*[a-z0-9])?)*$ ]]; then - log "error: DD_SITE contains unsupported hostname characters: '$raw'" - return 1 - fi - echo "$site" -}} - -# Resolve runfile path for context.json lookup -# Since `bazel run` does NOT set TEST_SRCDIR, we use RUNFILES_DIR or RUNFILES_MANIFEST_FILE -resolve_runfile() {{ - local input_rloc="$1" - local rloc="$input_rloc" - # Normalize relative prefixes that can appear in bzlmod runfile paths - rloc="${rloc#./}" - while [[ "$rloc" == ../* ]]; do - rloc="${rloc#../}" - done - # Defensive guard: runfile labels must remain repository-relative. - # We intentionally reject absolute paths and parent traversal segments so - # runfile resolution cannot escape the runfiles tree. - if [[ -z "$rloc" || "$rloc" == /* || "$rloc" =~ ^[A-Za-z]:/ || "$rloc" == ".." || "$rloc" == */.. || "$rloc" == */../* ]]; then - dbg "resolve_runfile: rejected suspicious runfile label '$input_rloc' (normalized='$rloc')" - echo "" - return - fi - local candidates=("$rloc") - if [[ "$rloc" == external/* ]]; then - candidates+=("${rloc#external/}") - else - # Try the external/ prefix when short_path omits it under bzlmod. - candidates+=("external/$rloc") - fi - if [[ "$rloc" != _main/* ]]; then - candidates+=("_main/$rloc") - fi - local manifest_file="${{RUNFILES_MANIFEST_FILE:-}}" - dbg "resolve_runfile: input='$input_rloc' normalized='$rloc' candidates='${{candidates[*]}}'" - if [[ -n "${{RUNFILES_DIR:-}}" ]]; then - local rf_state="missing" - if [[ -d "$RUNFILES_DIR" ]]; then - rf_state="dir" - elif [[ -e "$RUNFILES_DIR" ]]; then - rf_state="exists_non_dir" - fi - dbg "resolve_runfile: RUNFILES_DIR='$RUNFILES_DIR' state=$rf_state" - else - dbg "resolve_runfile: RUNFILES_DIR=" - fi - if [[ -n "$manifest_file" ]]; then - local mf_state="missing" - if [[ -f "$manifest_file" ]]; then - mf_state="file" - elif [[ -e "$manifest_file" ]]; then - mf_state="exists_non_file" - fi - dbg "resolve_runfile: RUNFILES_MANIFEST_FILE='$manifest_file' state=$mf_state" - else - dbg "resolve_runfile: RUNFILES_MANIFEST_FILE=" - fi - for cand in "${{candidates[@]}}"; do - dbg "resolve_runfile: trying candidate '$cand'" - # Try RUNFILES_DIR first (Unix default) - if [[ -n "${{RUNFILES_DIR:-}}" && -f "$RUNFILES_DIR/$cand" ]]; then - dbg "resolve_runfile: hit RUNFILES_DIR -> '$RUNFILES_DIR/$cand'" - echo "$RUNFILES_DIR/$cand" - return - fi - # Try $0.runfiles fallback - if [[ -f "$0.runfiles/$cand" ]]; then - dbg "resolve_runfile: hit script runfiles -> '$0.runfiles/$cand'" - echo "$0.runfiles/$cand" - return - fi - # Try RUNFILES_MANIFEST_FILE (Windows/manifest-only) - if [[ -n "$manifest_file" && -f "$manifest_file" ]]; then - local path - # Pass 1: exact manifest key match (preferred). - # Use awk + substr() for regex-free extraction, so candidate labels - # containing regex metacharacters are treated as plain text. - # We also strip a UTF-8 BOM from the first manifest key for parity - # with PowerShell and editors/tools that emit BOM-prefixed files. - path=$(awk -v key="$cand" ' - BEGIN {{ bom = sprintf("%c%c%c", 239, 187, 191) }} - {{ - k = $1 - if (NR == 1 && index(k, bom) == 1) {{ - k = substr(k, 4) - }} - if (k == key) {{ - print substr($0, length($1) + 2) - exit - }} - }} - ' "$manifest_file") - path=$(trim_ascii_whitespace "$path") - if [[ -n "$path" ]]; then - if [[ -f "$path" ]]; then - dbg "resolve_runfile: hit manifest exact key '$cand' -> '$path'" - echo "$path" - return - fi - dbg "resolve_runfile: manifest exact key '$cand' -> '$path' (not a file)" - fi - # Fallback: some manifests prefix keys with repo names (for example "/path/to/file"). - # Match entries whose key ends with "/" or "\\". - # Pass 2: suffix match for repo-prefixed key variants. - path=$(awk -v key="$cand" ' - BEGIN {{ bom = sprintf("%c%c%c", 239, 187, 191) }} - {{ - k = $1 - if (NR == 1 && index(k, bom) == 1) {{ - k = substr(k, 4) - }} - if (length(k) > length(key) && substr(k, length(k) - length(key) + 1) == key) {{ - sep = substr(k, length(k) - length(key), 1) - if (sep == "/" || sep == "\\\\") {{ - print substr($0, length($1) + 2) - exit - }} - }} - }} - ' "$manifest_file") - path=$(trim_ascii_whitespace "$path") - if [[ -n "$path" ]]; then - if [[ -f "$path" ]]; then - dbg "resolve_runfile: hit manifest suffix key '$cand' -> '$path'" - echo "$path" - return - fi - dbg "resolve_runfile: manifest suffix key '$cand' -> '$path' (not a file)" - fi - fi - done - dbg "resolve_runfile: miss for input '$input_rloc'" - echo "" # Not found -}} - -# Resolve execroot-relative artifact path (File.path). -# Bazel commonly provides paths like "external//..." relative to execroot. -resolve_artifact_path() {{ - local input_path="$1" - if [[ -z "$input_path" ]]; then - echo "" - return - fi - dbg "resolve_artifact_path: input='$input_path'" - if [[ -f "$input_path" ]]; then - dbg "resolve_artifact_path: hit direct -> '$input_path'" - echo "$input_path" - return - fi - local script_dir execroot candidate - script_dir=$(cd "$(dirname "$0")" && pwd -P) - execroot=$(cd "$script_dir/../../.." 2>/dev/null && pwd -P || true) - if [[ -n "$execroot" ]]; then - candidate="$execroot/$input_path" - if [[ -f "$candidate" ]]; then - dbg "resolve_artifact_path: hit execroot-relative -> '$candidate'" - echo "$candidate" - return - fi - fi - dbg "resolve_artifact_path: miss for input '$input_path'" - echo "" -}} - -# Resolve context.json path (used by upload functions for payload enrichment) -# Path is determined at rule implementation time from data files -CONTEXT_JSON_RLOC="{context_json_rloc}" -CONTEXT_JSON_PATH="{context_json_path}" -dbg "context.json resolution inputs: path='$CONTEXT_JSON_PATH' rloc='$CONTEXT_JSON_RLOC'" -CONTEXT_JSON=$(resolve_artifact_path "$CONTEXT_JSON_PATH") -if [[ -n "$CONTEXT_JSON" ]]; then - # Direct artifact path is fastest and most deterministic when available. - dbg "context.json resolved via direct path: '$CONTEXT_JSON'" -elif [[ -n "$CONTEXT_JSON_RLOC" ]]; then - # Runfiles lookup supports launcher/platform variants and bzlmod naming. - CONTEXT_JSON=$(resolve_runfile "$CONTEXT_JSON_RLOC") - if [[ -z "$CONTEXT_JSON" ]]; then - log "warning: context.json not found in runfiles; payloads will not be enriched" - else - dbg "context.json resolved via runfiles: '$CONTEXT_JSON'" - fi -else - CONTEXT_JSON="" - dbg "context.json not configured in data files; enrichment disabled" -fi - -# Resolve schema and validator paths (used for payload validation) -SCHEMA_JSON_RLOC="{schema_json_rloc}" -SCHEMA_JSON_PATH="{schema_json_path}" -SCHEMA_VALIDATOR_RLOC="{schema_validator_rloc}" -SCHEMA_VALIDATOR_PATH="{schema_validator_path}" -dbg "schema resolution inputs: schema_path='$SCHEMA_JSON_PATH' schema_rloc='$SCHEMA_JSON_RLOC' validator_path='$SCHEMA_VALIDATOR_PATH' validator_rloc='$SCHEMA_VALIDATOR_RLOC'" -SCHEMA_JSON=$(resolve_artifact_path "$SCHEMA_JSON_PATH") -if [[ -n "$SCHEMA_JSON" ]]; then - dbg "schema resolved via direct path: '$SCHEMA_JSON'" -elif [[ -n "$SCHEMA_JSON_RLOC" ]]; then - # Fallback to runfiles so validation still works under manifest-only setups. - SCHEMA_JSON=$(resolve_runfile "$SCHEMA_JSON_RLOC") - if [[ -z "$SCHEMA_JSON" ]]; then - log "warning: schema not found in runfiles; validation disabled" - else - dbg "schema resolved via runfiles: '$SCHEMA_JSON'" - fi -else - SCHEMA_JSON="" - dbg "schema not configured in data files; validation disabled" -fi -SCHEMA_VALIDATOR=$(resolve_artifact_path "$SCHEMA_VALIDATOR_PATH") -if [[ -n "$SCHEMA_VALIDATOR" ]]; then - dbg "schema validator resolved via direct path: '$SCHEMA_VALIDATOR'" -elif [[ -n "$SCHEMA_VALIDATOR_RLOC" ]]; then - # Keep parity with schema resolution order (direct path first, runfile second). - SCHEMA_VALIDATOR=$(resolve_runfile "$SCHEMA_VALIDATOR_RLOC") - if [[ -z "$SCHEMA_VALIDATOR" ]]; then - log "warning: schema validator not found in runfiles; validation disabled" - else - dbg "schema validator resolved via runfiles: '$SCHEMA_VALIDATOR'" - fi -else - SCHEMA_VALIDATOR="" - dbg "schema validator not configured in data files; validation disabled" -fi - -# Normalize boolean value (handles True/False from Starlark, 1/0, true/false) -# Uses tr for POSIX compatibility (macOS ships with Bash 3.2 which lacks ${{var,,}}) -normalize_bool() {{ - local val - val=$(echo "$1" | tr '[:upper:]' '[:lower:]') - case "$val" in - 1|true|yes) echo "1" ;; - *) echo "0" ;; - esac -}} - -# Validate numeric value; exit 2 if invalid -validate_numeric() {{ - local name="$1" - local val="$2" - if ! [[ "$val" =~ ^[0-9]+$ ]]; then - log "error: $name must be a non-negative integer, got: '$val'" - exit 2 # Configuration error - fi -}} - -# Generate UUID (best effort). Uses uuidgen, python3, or /dev/urandom. -generate_uuid() {{ - if command -v uuidgen >/dev/null 2>&1; then - uuidgen | tr '[:upper:]' '[:lower:]' - return - fi - if command -v python3 >/dev/null 2>&1; then - python3 - <<'PY' -import uuid -print(str(uuid.uuid4())) -PY - return - fi - if [[ -r /dev/urandom ]]; then - local hex - hex=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n') - echo "${{hex:0:8}}-${{hex:8:4}}-${{hex:12:4}}-${{hex:16:4}}-${{hex:20:12}}" - return - fi - echo "00000000-0000-0000-0000-000000000000" -}} - -# Compute FNV-1a 32-bit hex fingerprint (non-cryptographic, for parity checks only) -fnv1a_32() {{ - local input="$1" - if [[ -z "$input" ]]; then - echo "" - return - fi - local alphabet='0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_:/.+' - local hash=2166136261 - local input_len="${{#input}}" - local alpha_len="${{#alphabet}}" - local i j idx found ch ach - for ((i = 0; i < input_len; i++)); do - ch="${{input:i:1}}" - idx=0 - found=0 - for ((j = 0; j < alpha_len; j++)); do - ach="${{alphabet:j:1}}" - if [[ "$ach" == "$ch" ]]; then - idx=$j - found=1 - break - fi - done - if (( found == 0 )); then - idx=0 - fi - hash=$((hash ^ idx)) - hash=$(( (hash * 16777619) & 0xffffffff )) - done - printf '%08x' "$hash" -}} - -# Rule attributes (can be overridden via environment variables) -QUIESCENT_SEC=${{DD_TEST_OPTIMIZATION_QUIESCENT_SEC:-{quiescent_sec}}} -MAX_WAIT_SEC=${{DD_TEST_OPTIMIZATION_MAX_WAIT_SEC:-{max_wait_sec}}} -FAIL_ON_ERROR=$(normalize_bool "{fail_on_error}") -KEEP_PAYLOADS=$(normalize_bool "${{DD_TEST_OPTIMIZATION_KEEP_PAYLOADS:-{keep_payloads}}}") -FILTER_PREFIX=$(normalize_bool "${{DD_TEST_OPTIMIZATION_FILTER_PREFIX:-{filter_prefix}}}") -DEBUG=$(normalize_bool "${{DD_TEST_OPTIMIZATION_DEBUG:-{debug}}}") -GZIP_PAYLOADS=$(normalize_bool "${{DD_TEST_OPTIMIZATION_GZIP:-{gzip_payloads}}}") -RULES_VERSION="{rules_version}" -RUNTIME_ID=$(generate_uuid) - -# Validate numeric environment variables -validate_numeric "QUIESCENT_SEC" "$QUIESCENT_SEC" -validate_numeric "MAX_WAIT_SEC" "$MAX_WAIT_SEC" -if [[ -n "${{DD_TEST_OPTIMIZATION_MAX_DEPTH:-}}" ]]; then - validate_numeric "DD_TEST_OPTIMIZATION_MAX_DEPTH" "$DD_TEST_OPTIMIZATION_MAX_DEPTH" -fi -if [[ "$GZIP_PAYLOADS" == "1" ]]; then - if ! command -v gzip >/dev/null 2>&1; then - log "warning: DD_TEST_OPTIMIZATION_GZIP=1 but gzip not found; disabling gzip" - GZIP_PAYLOADS=0 - fi -fi -dbg "gzip enabled: $GZIP_PAYLOADS" - -# Baseline curl retry flags. We append --retry-all-errors only when supported -# by the installed curl binary (introduced in curl 7.85.0). -CURL_RETRY_FLAGS=({curl_retry_flags}) -if curl --help all 2>/dev/null | grep -q -- '--retry-all-errors'; then - CURL_RETRY_FLAGS+=(--retry-all-errors) -fi -dbg "curl retry flags: ${{CURL_RETRY_FLAGS[*]}}" - -# Windows detection - delegate to PowerShell if needed -if [[ "$(uname -s | tr 'A-Z' 'a-z')" == *mingw* || "$(uname -s | tr 'A-Z' 'a-z')" == *msys* || "$(uname -s | tr 'A-Z' 'a-z')" == *cygwin* ]]; then - ps_path="$(dirname "$0")/$(basename "$0" .sh).ps1" - dbg "Windows-like environment detected; delegating to PowerShell: $ps_path" - exec powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$ps_path" -fi - -# Acquire exclusive lock to prevent concurrent uploaders -# Uses mkdir for portability (works on macOS which lacks flock) -# Lock is scoped to workspace to allow parallel uploads in different workspaces -# Hash generation handles both Linux (md5sum) and macOS (md5 -q) formats -compute_workspace_hash() {{ - local workspace="${{BUILD_WORKSPACE_DIRECTORY:-$(pwd)}}" - # Try md5sum (Linux), then md5 -q (macOS), then shasum, then fallback - if command -v md5sum >/dev/null 2>&1; then - printf "%s" "$workspace" | md5sum | cut -c1-8 - elif command -v md5 >/dev/null 2>&1; then - printf "%s" "$workspace" | md5 -q | cut -c1-8 - elif command -v shasum >/dev/null 2>&1; then - printf "%s" "$workspace" | shasum -a 256 | cut -c1-8 - else - echo "default" - fi -}} -WORKSPACE_HASH=$(compute_workspace_hash) -LOCK_DIR="${{TMPDIR:-/tmp}}/dd_upload_payloads_$WORKSPACE_HASH.lock" -LOCK_ACQUIRED=0 - -lock_dir_age_seconds() {{ - local dir="$1" - local now mtime - # Cross-platform stat: - # - BSD/macOS: stat -f %m - # - GNU/Linux: stat -c %Y - now=$(date +%s) - if mtime=$(stat -f %m "$dir" 2>/dev/null); then - : - elif mtime=$(stat -c %Y "$dir" 2>/dev/null); then - : - else - echo 0 - return - fi - if [[ "$mtime" =~ ^[0-9]+$ ]]; then - echo $(( now - mtime )) - else - echo 0 - fi -}} - -acquire_lock() {{ - local max_attempts=3 - local attempt=0 - while (( attempt < max_attempts )); do - if mkdir "$LOCK_DIR" 2>/dev/null; then - # Persist PID metadata right after lock creation. If this write fails - # we treat the lock as unusable and immediately remove it. - if ! echo $$ > "$LOCK_DIR/pid" 2>/dev/null; then - rm -rf "$LOCK_DIR" 2>/dev/null || true - log "error: failed to initialize lock metadata at $LOCK_DIR/pid" - return 1 - fi - LOCK_ACQUIRED=1 - dbg "acquired lock: $LOCK_DIR (workspace hash: $WORKSPACE_HASH)" - return 0 - fi - # Check if lock is stale: - # 1) lock dir exists but pid file is empty/malformed - # 2) lock dir exists but pid file is missing - # 3) pid exists but process is no longer alive - if [[ -f "$LOCK_DIR/pid" ]]; then - local owner_pid - owner_pid=$(tr -d '[:space:]' < "$LOCK_DIR/pid" 2>/dev/null || echo "") - if [[ -z "$owner_pid" ]]; then - local lock_age - lock_age=$(lock_dir_age_seconds "$LOCK_DIR") - if [[ "$lock_age" =~ ^[0-9]+$ ]] && (( lock_age > 30 )); then - dbg "removing stale lock (empty pid file, age ${lock_age}s)" - rm -rf "$LOCK_DIR" 2>/dev/null || true - ((++attempt)) - continue - fi - ((++attempt)) - sleep 1 - continue - fi - if ! kill -0 "$owner_pid" 2>/dev/null; then - dbg "removing stale lock (pid $owner_pid is dead)" - rm -rf "$LOCK_DIR" 2>/dev/null || true - ((++attempt)) - continue - fi - else - local lock_age - lock_age=$(lock_dir_age_seconds "$LOCK_DIR") - if [[ "$lock_age" =~ ^[0-9]+$ ]] && (( lock_age > 30 )); then - dbg "removing stale lock (missing pid file, age ${lock_age}s)" - rm -rf "$LOCK_DIR" 2>/dev/null || true - ((++attempt)) - continue - fi - # Fresh lock without pid metadata might be in the middle of setup by - # another uploader; back off briefly before retrying. - ((++attempt)) - sleep 1 - continue - fi - log "error: another uploader is already running (lock: $LOCK_DIR)" - log "hint: wait for the other uploader to finish, or remove the lock directory if stale" - return 1 - done - return 1 -}} - -if ! acquire_lock; then - exit 2 -fi - -# Temporary working directory for enriched payloads / multipart event files -TMP_PAYLOAD_DIR="$(mktemp -d "${{TMPDIR:-/tmp}}/dd_topt_payloads.XXXXXX" 2>/dev/null || true)" -if [[ -z "$TMP_PAYLOAD_DIR" || ! -d "$TMP_PAYLOAD_DIR" ]]; then - log "error: failed to create temp directory for payload uploads" - rm -rf "$LOCK_DIR" 2>/dev/null || true - exit 2 -fi - -# Cleanup lock on exit -cleanup() {{ - # Only the lock owner may remove LOCK_DIR. This avoids deleting an active - # uploader's lock when the current process failed to acquire it. - if [[ "$LOCK_ACQUIRED" == "1" ]]; then - rm -rf "$LOCK_DIR" 2>/dev/null || true - fi - rm -rf "$TMP_PAYLOAD_DIR" 2>/dev/null || true -}} -trap cleanup EXIT - -# Determine bazel-testlogs directory -# Priority: TESTLOGS_DIR env var > BUILD_WORKSPACE_DIRECTORY/bazel-testlogs > ./bazel-testlogs -# -# NOTE: We intentionally do NOT call `bazel info` from within the uploader. -# Running `bazel info` inside `bazel run` can deadlock when the output base is locked. -# For non-standard setups (--symlink_prefix, disabled symlinks), users should set -# TESTLOGS_DIR externally using the same Bazel binary AND flags as for 'bazel test': -# BAZEL_FLAGS=("--output_base=/custom/base") -# TESTLOGS_DIR=$(bazel "${{BAZEL_FLAGS[@]}}" info bazel-testlogs) bazel "${{BAZEL_FLAGS[@]}}" run ... - -# Check explicit TESTLOGS_DIR override first (fail fast if set but invalid) -if [[ -n "${{TESTLOGS_DIR:-}}" ]]; then - if [[ -d "$TESTLOGS_DIR" ]]; then - # Explicit override wins over all discovery heuristics. - dbg "using explicit TESTLOGS_DIR=$TESTLOGS_DIR" - else - log "error: TESTLOGS_DIR is set but path does not exist: $TESTLOGS_DIR" - log "hint: ensure you used the same Bazel wrapper for 'bazel info' as for 'bazel test'" - exit 2 # Configuration error (see exit codes in docs) - fi -else - # Auto-discover testlogs directory - # Discovery order intentionally mirrors common Bazel invocation contexts: - # 1) BUILD_WORKSPACE_DIRECTORY (when provided by launcher) - # 2) local bazel-testlogs symlink in current directory - if [[ -n "${{BUILD_WORKSPACE_DIRECTORY:-}}" ]]; then - candidate="$BUILD_WORKSPACE_DIRECTORY/bazel-testlogs" - if [[ -d "$candidate" ]] || [[ -L "$candidate" ]]; then - TESTLOGS_DIR="$candidate" - fi - fi - - if [[ -z "${{TESTLOGS_DIR:-}}" ]] && {{ [[ -d "bazel-testlogs" ]] || [[ -L "bazel-testlogs" ]]; }}; then - TESTLOGS_DIR="$(pwd)/bazel-testlogs" - fi - - if [[ -z "${{TESTLOGS_DIR:-}}" ]]; then - log "warning: testlogs dir not found (nothing to upload)" - log "hint: set TESTLOGS_DIR env var, or ensure bazel-testlogs symlink exists" - # Exit 0 by default (graceful no-op), but respect FAIL_ON_ERROR to catch misconfigurations - if [[ "$FAIL_ON_ERROR" == "1" ]]; then - log "error: FAIL_ON_ERROR is set and no testlogs found - this may indicate misconfiguration" - exit 2 # Configuration error - fi - exit 0 - fi - - dbg "auto-discovered TESTLOGS_DIR=$TESTLOGS_DIR" -fi - -# Find all test.outputs directories -# Supports DD_TEST_OPTIMIZATION_MAX_DEPTH to limit search depth for large testlogs trees -MAX_DEPTH=${{DD_TEST_OPTIMIZATION_MAX_DEPTH:-0}} -find_test_outputs() {{ - local depth_args=() - if (( MAX_DEPTH > 0 )); then - depth_args=(-maxdepth "$MAX_DEPTH") - dbg "limiting find depth to $MAX_DEPTH" - fi - find "$TESTLOGS_DIR" "${{depth_args[@]+"${{depth_args[@]}}"}}" -type d -name "test.outputs" 2>/dev/null || true -}} - -# Warn if MAX_DEPTH is set and no test.outputs found (likely depth too shallow) -# Note: Must be called AFTER cache_test_outputs to use the cache -check_depth_warning() {{ - if [[ -z "$TEST_OUTPUTS_CACHE" ]] && (( MAX_DEPTH > 0 )); then - log "warning: DD_TEST_OPTIMIZATION_MAX_DEPTH=$MAX_DEPTH may be too shallow" - log "hint: typical test.outputs paths require depth 3-5; try increasing or removing the limit" - fi -}} - -# Detect stat flavor (BSD vs GNU) to choose correct flags -# GNU stat supports: stat -c %Y / (returns numeric mtime) -# BSD stat supports: stat -f %m / (returns numeric mtime) -STAT_FLAVOR="bsd" -if stat -c %Y / >/dev/null 2>&1; then - STAT_FLAVOR="gnu" -fi -dbg "stat detection: STAT_FLAVOR=$STAT_FLAVOR (uname=$(uname -s))" - -# Get latest mtime across payloads/tests and payloads/coverage in test.outputs. -# Note: Only scans payload directories, not all files under test.outputs -latest_mtime_all() {{ - local max_mtime=0 - while IFS= read -r outputs_dir; do - [[ -z "$outputs_dir" ]] && continue - for subdir in "payloads/tests" "payloads/coverage"; do - local dir="$outputs_dir/$subdir" - [[ -d "$dir" ]] || continue - local mt - if [[ "$STAT_FLAVOR" == "bsd" ]]; then - mt=$(find "$dir" -type f -name "*.json" -exec stat -f '%m' {{}} + 2>/dev/null | sort -nr | head -1 || echo 0) - else - mt=$(find "$dir" -type f -name "*.json" -exec stat -c '%Y' {{}} + 2>/dev/null | sort -nr | head -1 || echo 0) - fi - mt=${{mt:-0}} - if (( mt > max_mtime )); then - max_mtime=$mt - fi - done - done < <(echo "$TEST_OUTPUTS_CACHE") - echo "$max_mtime" -}} - -# Count total payload files across all test.outputs payload directories. -count_payload_files() {{ - local count=0 - while IFS= read -r outputs_dir; do - [[ -z "$outputs_dir" ]] && continue - local tests_dir="$outputs_dir/payloads/tests" - local cov_dir="$outputs_dir/payloads/coverage" - if [[ -d "$tests_dir" ]]; then - local tests_count - tests_count=$(find "$tests_dir" -name "*.json" 2>/dev/null | wc -l) - count=$((count + tests_count)) - fi - if [[ -d "$cov_dir" ]]; then - local cov_count - cov_count=$(find "$cov_dir" -name "*.json" 2>/dev/null | wc -l) - count=$((count + cov_count)) - fi - done < <(echo "$TEST_OUTPUTS_CACHE") - echo "$count" -}} - -start_ts=$(date +%s) -dbg "Uploader start time: $start_ts" - -# Detect if tests actually ran by looking for test.log or test.xml files -# This helps distinguish "no payloads because tests didn't run" from "tests ran but dd-trace-go is misconfigured" -tests_executed() {{ - local found - found=$(find "$TESTLOGS_DIR" \\( -name "test.log" -o -name "test.xml" \\) -type f -print -quit 2>/dev/null) - [[ -n "$found" ]] -}} - -# Wait for quiescence (filesystem to settle) -# Since the uploader runs AFTER tests complete (via `bazel run` after `bazel test`), -# we just need a short quiescence period to ensure all files are written. -dbg "Waiting for test outputs to quiesce..." - -# Cache the list of test.outputs directories for efficiency (avoid rescanning on each loop iteration) -TEST_OUTPUTS_CACHE="" -cache_test_outputs() {{ - TEST_OUTPUTS_CACHE=$(find_test_outputs) -}} -cache_test_outputs -check_depth_warning # Warn if MAX_DEPTH may be too shallow - -while true; do - now=$(date +%s) - elapsed=$((now - start_ts)) - - # Refresh cache in case new test.outputs dirs appeared (e.g., remote downloads) - cache_test_outputs - total_files=$(count_payload_files) - - if (( total_files == 0 )); then - # No payloads yet. Branch behavior depends on max-wait policy: - # - MAX_WAIT_SEC=0: immediate decision (upload no-op or fail-on-error) - # - MAX_WAIT_SEC>0: keep polling until timeout - if (( MAX_WAIT_SEC == 0 )); then - if tests_executed; then - log "warning: tests ran but no payload files found" - log "hint: check that DD_TEST_OPTIMIZATION_PAYLOADS_IN_FILES=true is set" - if [[ "$FAIL_ON_ERROR" == "1" ]]; then - log "error: FAIL_ON_ERROR is set; failing due to missing payloads" - exit 1 - fi - else - log "no payload files found and no test execution detected; nothing to upload" - fi - exit 0 - fi - if (( elapsed > MAX_WAIT_SEC )); then - if tests_executed; then - log "warning: tests ran but no payload files found" - log "hint: check that DD_TEST_OPTIMIZATION_PAYLOADS_IN_FILES=true is set" - if [[ "$FAIL_ON_ERROR" == "1" ]]; then - log "error: FAIL_ON_ERROR is set; failing due to missing payloads" - exit 1 - fi - else - log "no payload files found and no test execution detected; nothing to upload" - fi - exit 0 - fi - dbg "no payload files yet; waiting" - sleep 2 - continue - fi - - if (( elapsed > MAX_WAIT_SEC )); then - # Payloads exist but waiting budget is exhausted; proceed anyway. - log "max wait exceeded ($MAX_WAIT_SEC s); proceeding to upload" - break - fi - - # Check if files have been stable for QUIESCENT_SEC - cur=$(latest_mtime_all) - idle=$((now - cur)) - dbg "total_files=$total_files, idle=$idle s" - - if (( idle >= QUIESCENT_SEC )); then - log "outputs quiescent for $idle s ($total_files files); starting upload" - break - fi - - sleep 2 -done - -# Build endpoints -if ! DD_SITE="$(normalize_dd_site_or_fail "${{DD_SITE:-datadoghq.com}}")"; then - exit 2 -fi -INTAKE_BASE="${{DD_TEST_OPTIMIZATION_INTAKE_BASE:-}}" -if [[ -z "${{DD_TRACE_AGENT_URL:-}}" ]]; then - # Agentless mode: direct public intake URLs (or explicit override base). - AGENTLESS=1 - if [[ -n "$INTAKE_BASE" ]]; then - # Allow tests/dev to override intake base without changing DD_SITE. - BASE="${{INTAKE_BASE%/}}" - TEST_URL="${{BASE}}/api/v2/citestcycle" - COV_URL="${{BASE}}/api/v2/citestcov" - dbg "DD_TEST_OPTIMIZATION_INTAKE_BASE override active: $BASE" - else - TEST_URL="https://citestcycle-intake.${{DD_SITE}}/api/v2/citestcycle" - COV_URL="https://citestcov-intake.${{DD_SITE}}/api/v2/citestcov" - fi -else - # EVP mode: route through agent endpoint with required subdomain headers. - AGENTLESS=0 - TEST_URL="${{DD_TRACE_AGENT_URL}}/evp_proxy/v2/api/v2/citestcycle" - COV_URL="${{DD_TRACE_AGENT_URL}}/evp_proxy/v2/api/v2/citestcov" - if [[ -n "$INTAKE_BASE" ]]; then - dbg "DD_TEST_OPTIMIZATION_INTAKE_BASE ignored in EVP mode" - fi -fi -dbg "mode: AGENTLESS=$AGENTLESS DD_SITE=$DD_SITE" -dbg "endpoints: TEST_URL=$TEST_URL COV_URL=$COV_URL" - -HEADER_LANG_DEFAULT="bazel-starlark" -HEADER_LANG_VERSION_DEFAULT="n/a" -HEADER_LANG_INTERPRETER_DEFAULT="bazel-run" -HEADER_TRACER_VERSION_DEFAULT="{uploader_version}" -if (( AGENTLESS == 1 )); then - if [[ -z "${{DD_API_KEY:-}}" ]]; then - log "error: DD_API_KEY required for agentless uploads" - log "hint: pass credentials via environment: DD_API_KEY=... DD_SITE=... bazel run //:dd_upload_payloads" - exit 2 # Configuration error - fi -else - # EVP subdomain headers per endpoint - TEST_EVP=( -H "X-Datadog-EVP-Subdomain: citestcycle-intake" ) - COV_EVP=( -H "X-Datadog-EVP-Subdomain: citestcov-intake" ) -fi -dbg "headers prepared (agentless=$AGENTLESS; test headers can be derived from metadata)" - -# Redact sensitive header values (keep last 4 chars for DD-API-KEY) -redact_header() {{ - local h="$1" - local name="${{h%%:*}}" - if [[ "$name" == "DD-API-KEY" ]]; then - local val="${{h#*:}}" - val="${{val# }}"; val="${{val% }}"; val="${{val%%$'\\r'}}" - if (( ${{#val}} > 4 )); then - echo "DD-API-KEY: ****${{val: -4}}" - else - echo "DD-API-KEY: $val" - fi - else - echo "$h" - fi -}} - -dbg_headers() {{ - local label="$1"; shift - local arr=("$@") - local i=0 - while (( i < ${{#arr[@]}} )); do - if [[ "${{arr[$i]}}" == "-H" && $((i+1)) -lt ${{#arr[@]}} ]]; then - dbg "header[$label]: $(redact_header "${{arr[$((i+1))]}}")" - i=$((i+2)) - continue - fi - dbg "header[$label]: ${{arr[$i]}}" - i=$((i+1)) - done -}} - -# Load context.json for enrichment -JQ_AVAILABLE=0 -if command -v jq >/dev/null 2>&1; then JQ_AVAILABLE=1; fi -dbg "jq available: $JQ_AVAILABLE" -dbg "context.json: ${{CONTEXT_JSON:-}}" - -# CODEOWNERS state (initialized lazily on first enrichment attempt). -CODEOWNERS_INITIALIZED=0 -CODEOWNERS_ENABLED=0 -CODEOWNERS_FILE="" -CODEOWNERS_WORKSPACE_ROOT="" -CODEOWNERS_CONTEXT_WORKSPACE="" -CODEOWNERS_RULE_REGEX=() -CODEOWNERS_RULE_OWNERS=() -CODEOWNERS_RULE_HAS_OWNERS=() -CODEOWNERS_SOURCE_CANDIDATES=() -CODEOWNERS_MATCH_NONE="__DD_CODEOWNERS_NO_MATCH__" -CODEOWNERS_MATCH_EMPTY="__DD_CODEOWNERS_EMPTY_OWNERS__" -CODEOWNERS_SPLIT_PATTERN="" -CODEOWNERS_SPLIT_OWNERS_RAW="" -CO_EVENTS_SCANNED=0 -CO_EVENTS_ENRICHED=0 -CO_EVENTS_SKIPPED_EXISTING=0 -CO_EVENTS_SKIPPED_MISSING_SOURCE=0 -CO_EVENTS_SKIPPED_UNMATCHED=0 -CO_EVENTS_SKIPPED_ERRORS=0 - -decode_percent_path() {{ - local value="$1" - if [[ "$value" != *"%"* ]]; then - echo "$value" - return - fi - # Avoid introducing NUL bytes into shell strings. - if [[ "$value" == *"%00"* ]]; then - echo "$value" - return - fi - # Decode only when every '%' participates in a valid %XX sequence. - # This keeps behavior deterministic for malformed input. - local stripped - stripped=$(echo "$value" | sed -E 's/%[0-9A-Fa-f]{2}//g') - if [[ "$stripped" == *"%"* ]]; then - echo "$value" - return - fi - local decoded - decoded=$(printf '%b' "${{value//%/\\\\x}}" 2>/dev/null || true) - if [[ -n "$decoded" ]]; then - echo "$decoded" - else - echo "$value" - fi -}} - -normalize_path_like() {{ - local raw="$1" - if [[ "$raw" == file://* ]]; then - raw="${{raw#file://}}" - fi - raw=$(decode_percent_path "$raw") - # Decode can re-introduce backslashes (for example %5C on Windows paths). - # Normalize after decoding so slash-based matching stays consistent. - raw="${{raw//\\\\//}}" - # Collapse duplicated separators to improve matching stability. - while [[ "$raw" == *"//"* ]]; do - raw=$(echo "$raw" | sed -E 's#/{2,}#/#g') - done - while [[ "$raw" == ./* ]]; do - raw="${{raw#./}}" - done - if [[ "$raw" =~ ^/[A-Za-z]:/ ]]; then - # file:///C:/... style paths become /C:/... after scheme removal. - # Drop only the leading slash to preserve the drive-qualified path. - raw="${{raw:1}}" - fi - - local is_abs=0 - if [[ "$raw" == /* ]]; then - is_abs=1 - raw="${{raw#/}}" - fi - - # Canonicalize dot segments. If normalization would escape above root, - # return failure so caller can skip unsafe/invalid candidates. - local -a parts=() - local -a stack=() - local part idx - IFS='/' read -r -a parts <<< "$raw" - for part in "${{parts[@]}}"; do - case "$part" in - ""|".") - continue - ;; - "..") - if (( ${{#stack[@]}} > 0 )); then - idx=$(( ${{#stack[@]}} - 1 )) - unset "stack[$idx]" - stack=("${{stack[@]}}") - else - echo "" - return 1 - fi - ;; - *) - stack+=("$part") - ;; - esac - done - - local joined="" - if (( ${{#stack[@]}} > 0 )); then - joined="${{stack[0]}}" - for ((idx = 1; idx < ${{#stack[@]}}; idx++)); do - joined="$joined/${{stack[$idx]}}" - done - fi - - if (( is_abs == 1 )); then - echo "/$joined" - else - echo "$joined" - fi - return 0 -}} - -add_path_candidate() {{ - local candidate="$1" - local normalized - normalized=$(normalize_path_like "$candidate" || true) - [[ -z "$normalized" ]] && return - normalized="${{normalized#/}}" - while [[ "$normalized" == ./* ]]; do - normalized="${{normalized#./}}" - done - [[ -z "$normalized" ]] && return - # Generated output paths do not map to repository-owned source files. - [[ "$normalized" == bazel-out/* ]] && return - local existing - if (( ${{#CODEOWNERS_SOURCE_CANDIDATES[@]}} > 0 )); then - for existing in "${{CODEOWNERS_SOURCE_CANDIDATES[@]}}"; do - [[ "$existing" == "$normalized" ]] && return - done - fi - CODEOWNERS_SOURCE_CANDIDATES+=("$normalized") -}} - -add_derived_source_candidate() {{ - local candidate="$1" - if [[ "$candidate" == external/* || "$candidate" == _main/external/* ]]; then - # Execroot/runfiles derived external paths belong to fetched dependencies, - # not repository-owned source files. Skip to avoid false owner attribution. - [[ "$DEBUG" == "1" ]] && dbg "codeowners: skip external source candidate '$candidate'" - return - fi - add_path_candidate "$candidate" -}} - -strip_workspace_prefix() {{ - local path_value="$1" - local root_value="$2" - [[ -z "$path_value" || -z "$root_value" ]] && return - local path_norm root_norm - path_norm=$(normalize_path_like "$path_value" || true) - root_norm=$(normalize_path_like "$root_value" || true) - [[ -z "$path_norm" || -z "$root_norm" ]] && return - if [[ "$path_norm" == "$root_norm" ]]; then - echo "" - return - fi - if [[ "$path_norm" == "$root_norm/"* ]]; then - echo "${{path_norm#"$root_norm/"}}" - fi -}} - -build_source_candidates() {{ - local source_path="$1" - CODEOWNERS_SOURCE_CANDIDATES=() - local normalized_source stripped - normalized_source=$(normalize_path_like "$source_path" || true) - [[ -z "$normalized_source" ]] && return - - stripped=$(strip_workspace_prefix "$normalized_source" "$CODEOWNERS_CONTEXT_WORKSPACE") - [[ -n "$stripped" ]] && add_path_candidate "$stripped" - stripped=$(strip_workspace_prefix "$normalized_source" "$CODEOWNERS_WORKSPACE_ROOT") - [[ -n "$stripped" ]] && add_path_candidate "$stripped" - - if [[ "$normalized_source" =~ /execroot/[^/]+/_main/(.+)$ ]]; then - add_derived_source_candidate "${{BASH_REMATCH[1]}}" - fi - if [[ "$normalized_source" =~ /execroot/[^/]+/(.+)$ ]]; then - add_derived_source_candidate "${{BASH_REMATCH[1]}}" - fi - if [[ "$normalized_source" =~ \\.runfiles/_main/(.+)$ ]]; then - add_derived_source_candidate "${{BASH_REMATCH[1]}}" - fi - if [[ "$normalized_source" =~ \\.runfiles/[^/]+/(.+)$ ]]; then - add_derived_source_candidate "${{BASH_REMATCH[1]}}" - fi - # Keep only repository-relative fallback candidates. Absolute paths that are - # not under known repo roots can incorrectly inherit broad CODEOWNERS rules. - if [[ "$normalized_source" != /* && ! "$normalized_source" =~ ^[A-Za-z]:/ ]]; then - add_path_candidate "$normalized_source" - elif [[ "$DEBUG" == "1" ]]; then - dbg "codeowners: skip absolute source fallback candidate '$normalized_source'" - fi -}} - -glob_to_regex() {{ - local pattern="$1" - local out="" - local i=0 - local plen="${{#pattern}}" - local ch nxt j class_ch class_body class_closed - while (( i < plen )); do - ch="${{pattern:i:1}}" - # Backslash escapes the next glob metacharacter literally. - if [[ "$ch" == "\\\\" ]]; then - if (( i + 1 < plen )); then - nxt="${{pattern:i+1:1}}" - case "$nxt" in - "."|"+"|"("|")"|"{"|"}"|"^"|"$"|"|"|"["|"]"|"*"|"?"|"\\\\") - if [[ "$nxt" == "\\\\" ]]; then - out="$out\\\\\\\\" - else - out="$out\\\\$nxt" - fi - ;; - *) - out="$out$nxt" - ;; - esac - i=$((i + 2)) - else - out="$out\\\\\\\\" - i=$((i + 1)) - fi - continue - fi - if [[ "$ch" == "*" ]] && (( i + 1 < plen )); then - nxt="${{pattern:i+1:1}}" - if [[ "$nxt" == "*" ]]; then - if (( i + 2 < plen )) && [[ "${{pattern:i+2:1}}" == "/" ]]; then - # CODEOWNERS follows gitignore-style globbing: **/ matches zero or more directories. - out="${out}(.*/)?" - i=$((i + 3)) - else - out="${out}.*" - i=$((i + 2)) - fi - continue - fi - fi - if [[ "$ch" == "[" ]]; then - # Preserve character class semantics (including "!"/"^" negation). - j=$((i + 1)) - class_body="" - class_closed=0 - if (( j < plen )) && [[ "${{pattern:j:1}}" == "!" ]]; then - class_body="^" - j=$((j + 1)) - elif (( j < plen )) && [[ "${{pattern:j:1}}" == "^" ]]; then - class_body="\\\\^" - j=$((j + 1)) - fi - if (( j < plen )) && [[ "${{pattern:j:1}}" == "]" ]]; then - class_body="$class_body\\\\]" - j=$((j + 1)) - fi - while (( j < plen )); do - class_ch="${{pattern:j:1}}" - if [[ "$class_ch" == "]" ]]; then - class_closed=1 - break - fi - case "$class_ch" in - "\\\\") - class_body="$class_body\\\\\\\\" - ;; - "^") - class_body="$class_body\\\\^" - ;; - "[") - class_body="$class_body\\\\[" - ;; - *) - class_body="$class_body$class_ch" - ;; - esac - j=$((j + 1)) - done - if (( class_closed == 1 )); then - out="${out}[$class_body]" - i=$((j + 1)) - continue - fi - out="${out}\\\\[" - i=$((i + 1)) - continue - fi - case "$ch" in - "*") - out="${out}[^/]*" - ;; - "?") - out="${out}[^/]" - ;; - "."|"+"|"("|")"|"{"|"}"|"^"|"$"|"|"|"\\\\") - out="${out}\\\\$ch" - ;; - "]") - out="${out}\\\\]" - ;; - *) - out="${out}$ch" - ;; - esac - i=$((i + 1)) - done - echo "$out" -}} - -compile_codeowners_regex() {{ - local pattern="$1" - local anchored=0 - local dir_only=0 - if [[ "$pattern" == /* ]]; then - anchored=1 - pattern="${{pattern#/}}" - fi - if [[ "$pattern" == */ ]]; then - dir_only=1 - pattern="${{pattern%/}}" - fi - [[ -z "$pattern" ]] && return 1 - - local has_slash=0 - [[ "$pattern" == */* ]] && has_slash=1 - local body - body=$(glob_to_regex "$pattern") - local prefix suffix regex - # Match semantics: - # - anchored or slash-containing patterns match from repo root - # - plain patterns match at any path segment boundary - if (( anchored == 1 || has_slash == 1 )); then - prefix="^" - else - prefix="(^|.*/)" - fi - if (( dir_only == 1 )); then - suffix="/.*$" - else - suffix="($|/.*)" - fi - regex="$prefix$body$suffix" - echo "$regex" - return 0 -}} - -parse_codeowners_file() {{ - local file_path="$1" - local line pattern rest regex - local -a owner_tokens=() - while IFS= read -r line || [[ -n "$line" ]]; do - line="${{line%$'\\r'}}" - line="${{line#"${{line%%[![:space:]]*}}"}}" - [[ -z "$line" || "${{line:0:1}}" == "#" ]] && continue - # Section headers may include spaces (for example "[Core Team] @org/team"). - # Detect them from the full raw line before splitting on whitespace. - if is_gitlab_section_header_line "$line"; then - continue - fi - split_codeowners_pattern_and_owners "$line" - pattern="$CODEOWNERS_SPLIT_PATTERN" - rest="$CODEOWNERS_SPLIT_OWNERS_RAW" - # Ignore GitLab section headers while preserving bracket-class glob rules. - # This keeps patterns like "[xy] @team/owners" valid CODEOWNERS entries. - if is_gitlab_section_header_pattern "$pattern"; then - continue - fi - # Strip comments in owner segments while preserving '#' inside owner tokens. - # Example: "@org/team#chat" stays intact, while " @org/team # note" strips note. - if [[ "$rest" == "#"* ]]; then - rest="" - elif [[ "$rest" == *[[:space:]]#* ]]; then - rest=$(printf '%s\n' "$rest" | sed -E 's/[[:space:]]#.*$//') - fi - rest="${{rest%"${{rest##*[![:space:]]}}"}}" - [[ -z "$pattern" ]] && continue - owner_tokens=() - if [[ -n "$rest" ]]; then - read -r -a owner_tokens <<< "$rest" - fi - regex=$(compile_codeowners_regex "$pattern" || true) - [[ -z "$regex" ]] && continue - # Some character-class patterns can produce invalid POSIX ERE fragments - # (for example "[z-a]"). Validate here so malformed rules are skipped once - # at parse time instead of repeatedly triggering regex-eval errors later. - if ! codeowners_regex_is_valid "$regex"; then - [[ "$DEBUG" == "1" ]] && dbg "codeowners: skipping invalid regex '$regex' from pattern '$pattern'" - continue - fi - CODEOWNERS_RULE_REGEX+=("$regex") - if (( ${{#owner_tokens[@]}} == 0 )); then - CODEOWNERS_RULE_OWNERS+=("") - CODEOWNERS_RULE_HAS_OWNERS+=("0") - else - CODEOWNERS_RULE_OWNERS+=("$rest") - CODEOWNERS_RULE_HAS_OWNERS+=("1") - fi - if [[ "$DEBUG" == "1" ]]; then - local owners_dbg="" - if (( ${{#owner_tokens[@]}} > 0 )); then - owners_dbg="$rest" - fi - dbg "codeowners: parsed rule pattern='$pattern' regex='$regex' owners='$owners_dbg'" - fi - done < "$file_path" -}} - -is_gitlab_section_header_pattern() {{ - local pattern="$1" - [[ "$pattern" =~ ^\\[[^][]+\\]$ ]] || return 1 - local inner="${{pattern:1:${{#pattern}}-2}}" - # GitLab section headers can include whitespace (for example [Core Team]). - if [[ "$inner" == *[[:space:]]* ]]; then - return 0 - fi - # Heuristic to avoid class-only glob false positives: - # keep range-like and short bracket classes (for example [xy], [A-Z]). - if [[ "$inner" == *"-"* || "$inner" == *"!"* || "$inner" == *"^"* || "$inner" == *"\\\\"* ]]; then - return 1 - fi - # Preserve all-uppercase/digit class sets such as [ABCD] and [A1B2C3]. - if [[ "$inner" =~ ^[A-Z0-9]+$ ]]; then - return 1 - fi - # Preserve short alnum bracket classes (for example [xy], [ABC], [Abc]). - if (( ${{#inner}} <= 3 )) && [[ "$inner" =~ ^[A-Za-z0-9]+$ ]]; then - return 1 - fi - # Preserve plain lowercase/digit class sets such as [abc] and [a1b2]. - if [[ "$inner" =~ ^[a-z0-9]+$ ]]; then - return 1 - fi - return 0 -}} - -is_gitlab_section_header_line() {{ - local line="$1" - if [[ "$line" =~ ^(\\[[^][]+\\])([[:space:]]+.*)?$ ]]; then - is_gitlab_section_header_pattern "${{BASH_REMATCH[1]}}" - return $? - fi - return 1 -}} - -codeowners_regex_is_valid() {{ - local regex="$1" - local status=0 - # Run the probe inside `if` so set -e does not abort on a normal no-match. - if ( [[ "" =~ $regex ]] ) 2>/dev/null; then - status=0 - else - status=$? - fi - # Bash returns: - # 0 => matched - # 1 => valid regex, no match - # 2 => invalid regex syntax - if (( status == 0 || status == 1 )); then - return 0 - fi - return 1 -}} - -split_codeowners_pattern_and_owners() {{ - local line="$1" - local pattern="" - local rest="" - local i ch escaped=0 - local line_len="${{#line}}" - for ((i = 0; i < line_len; i++)); do - ch="${{line:i:1}}" - if (( escaped == 1 )); then - pattern="$pattern$ch" - escaped=0 - continue - fi - if [[ "$ch" == "\\\\" ]]; then - pattern="$pattern$ch" - escaped=1 - continue - fi - # Split on the first unescaped whitespace character. - # We intentionally use a character-class check (instead of only " " and - # tab) to match CODEOWNERS behavior for any ASCII whitespace separator. - if [[ "$ch" =~ [[:space:]] ]]; then - rest="${{line:i}}" - rest="${{rest#"${{rest%%[![:space:]]*}}"}}" - CODEOWNERS_SPLIT_PATTERN="$pattern" - CODEOWNERS_SPLIT_OWNERS_RAW="$rest" - return 0 - fi - pattern="$pattern$ch" - done - CODEOWNERS_SPLIT_PATTERN="$pattern" - CODEOWNERS_SPLIT_OWNERS_RAW="" - return 0 -}} - -init_codeowners() {{ - (( CODEOWNERS_INITIALIZED == 1 )) && return - CODEOWNERS_INITIALIZED=1 - if [[ -n "${{BUILD_WORKSPACE_DIRECTORY:-}}" ]]; then - CODEOWNERS_WORKSPACE_ROOT="$BUILD_WORKSPACE_DIRECTORY" - elif [[ -n "${{TESTLOGS_DIR:-}}" && "$TESTLOGS_DIR" == */bazel-testlogs* ]]; then - CODEOWNERS_WORKSPACE_ROOT="${{TESTLOGS_DIR%%/bazel-testlogs*}}" - else - CODEOWNERS_WORKSPACE_ROOT="$(pwd)" - fi - [[ -z "$CODEOWNERS_WORKSPACE_ROOT" ]] && CODEOWNERS_WORKSPACE_ROOT="$(pwd)" - CODEOWNERS_CONTEXT_WORKSPACE="" - if (( JQ_AVAILABLE == 1 )) && [[ -n "$CONTEXT_JSON" && -f "$CONTEXT_JSON" ]]; then - CODEOWNERS_CONTEXT_WORKSPACE=$(jq -r '."ci.workspace_path" // empty' "$CONTEXT_JSON" 2>/dev/null || true) - fi - - local explicit_codeowners="${{DD_TEST_OPTIMIZATION_CODEOWNERS_FILE:-}}" - if [[ -n "$explicit_codeowners" ]]; then - [[ "$DEBUG" == "1" ]] && dbg "codeowners: explicit path candidate '$explicit_codeowners'" - if [[ -f "$explicit_codeowners" && -r "$explicit_codeowners" ]]; then - CODEOWNERS_FILE="$explicit_codeowners" - dbg "codeowners: using explicit CODEOWNERS file '$CODEOWNERS_FILE'" - else - dbg "codeowners: DD_TEST_OPTIMIZATION_CODEOWNERS_FILE is set but not readable: '$explicit_codeowners' (falling back to discovery)" - fi - fi - - local script_dir - script_dir=$(cd "$(dirname "$0")" && pwd -P) - local -a candidates=() - if [[ -z "$CODEOWNERS_FILE" ]]; then - # Lookup order is intentional and mirrored in PowerShell implementation. - # We prefer `ci.workspace_path` when present, then workspace-derived paths, - # then process cwd, then script directory fallback. - if [[ -n "$CODEOWNERS_CONTEXT_WORKSPACE" ]]; then - candidates+=( - "$CODEOWNERS_CONTEXT_WORKSPACE/CODEOWNERS" - "$CODEOWNERS_CONTEXT_WORKSPACE/.github/CODEOWNERS" - "$CODEOWNERS_CONTEXT_WORKSPACE/.gitlab/CODEOWNERS" - "$CODEOWNERS_CONTEXT_WORKSPACE/docs/CODEOWNERS" - "$CODEOWNERS_CONTEXT_WORKSPACE/.docs/CODEOWNERS" - ) - fi - if [[ -n "$CODEOWNERS_WORKSPACE_ROOT" ]]; then - candidates+=( - "$CODEOWNERS_WORKSPACE_ROOT/CODEOWNERS" - "$CODEOWNERS_WORKSPACE_ROOT/.github/CODEOWNERS" - "$CODEOWNERS_WORKSPACE_ROOT/.gitlab/CODEOWNERS" - "$CODEOWNERS_WORKSPACE_ROOT/docs/CODEOWNERS" - "$CODEOWNERS_WORKSPACE_ROOT/.docs/CODEOWNERS" - ) - fi - candidates+=( - "./CODEOWNERS" - "$script_dir/CODEOWNERS" - ) - - local candidate - for candidate in "${{candidates[@]}}"; do - [[ -z "$candidate" ]] && continue - [[ "$DEBUG" == "1" && -f "$candidate" ]] && dbg "codeowners: discovery candidate hit '$candidate'" - if [[ -f "$candidate" && -r "$candidate" ]]; then - CODEOWNERS_FILE="$candidate" - break - fi - done - fi - - if [[ -z "$CODEOWNERS_FILE" ]]; then - dbg "codeowners: no CODEOWNERS file found (workspace='$CODEOWNERS_WORKSPACE_ROOT')" - return - fi - - parse_codeowners_file "$CODEOWNERS_FILE" - if (( ${{#CODEOWNERS_RULE_REGEX[@]}} > 0 )); then - CODEOWNERS_ENABLED=1 - dbg "codeowners: using '$CODEOWNERS_FILE' with ${{#CODEOWNERS_RULE_REGEX[@]}} rule(s)" - else - dbg "codeowners: file '$CODEOWNERS_FILE' had no usable rules" - fi -}} - -dedupe_owners() {{ - local owners_line="$1" - local -a in_tokens=() - local -a out_tokens=() - local token existing seen - read -r -a in_tokens <<< "$owners_line" - for token in "${{in_tokens[@]}}"; do - [[ -z "$token" ]] && continue - seen=0 - if (( ${{#out_tokens[@]}} > 0 )); then - for existing in "${{out_tokens[@]}}"; do - if [[ "$existing" == "$token" ]]; then - seen=1 - break - fi - done - fi - (( seen == 0 )) && out_tokens+=("$token") - done - if (( ${{#out_tokens[@]}} > 0 )); then - printf '%s\n' "${{out_tokens[@]}}" - fi -}} - -owners_line_to_json() {{ - local owners_line="$1" - local deduped - deduped=$(dedupe_owners "$owners_line" | jq -R . | jq -s -c '.' 2>/dev/null || true) - if [[ "$deduped" == "[]" ]]; then - echo "" - else - echo "$deduped" - fi -}} - -match_codeowners_owners_line() {{ - local candidate="$1" - local idx regex owners_line rule_has_owners matched="$CODEOWNERS_MATCH_NONE" - # Last matching CODEOWNERS rule wins. - for ((idx = 0; idx < ${{#CODEOWNERS_RULE_REGEX[@]}}; idx++)); do - regex="${{CODEOWNERS_RULE_REGEX[$idx]}}" - owners_line="${{CODEOWNERS_RULE_OWNERS[$idx]}}" - rule_has_owners="${{CODEOWNERS_RULE_HAS_OWNERS[$idx]}}" - if [[ "$candidate" =~ $regex ]]; then - if [[ "$rule_has_owners" == "1" ]]; then - matched="$owners_line" - else - matched="$CODEOWNERS_MATCH_EMPTY" - fi - fi - done - echo "$matched" -}} - -resolve_codeowners_json_for_source() {{ - local source_path="$1" - build_source_candidates "$source_path" - local candidate owners_line owners_json - # Candidate order matters: prefer repo-relative derivations before broader - # fallbacks so ownership reflects the most likely source path. - for candidate in "${{CODEOWNERS_SOURCE_CANDIDATES[@]}}"; do - owners_line=$(match_codeowners_owners_line "$candidate") - if [[ "$DEBUG" == "1" ]]; then - if [[ "$owners_line" == "$CODEOWNERS_MATCH_NONE" ]]; then - dbg "codeowners: candidate='$candidate' owners=''" - elif [[ "$owners_line" == "$CODEOWNERS_MATCH_EMPTY" ]]; then - dbg "codeowners: candidate='$candidate' owners=''" - else - dbg "codeowners: candidate='$candidate' owners='$owners_line'" - fi - fi - if [[ "$owners_line" == "$CODEOWNERS_MATCH_NONE" ]]; then - continue - fi - if [[ "$owners_line" == "$CODEOWNERS_MATCH_EMPTY" ]]; then - # Explicit "no owners" rule matched; treat as no tag. - # This preserves CODEOWNERS semantics where later empty-owner rules - # intentionally clear ownership for matching paths. - echo "" - return - fi - if [[ -n "$owners_line" ]]; then - owners_json=$(owners_line_to_json "$owners_line") - if [[ -n "$owners_json" ]]; then - echo "$owners_json" - return - fi - fi - done - echo "" -}} - -inject_codeowners_tags() {{ - local payload_file="$1" - init_codeowners - (( CODEOWNERS_ENABLED == 1 )) || return 0 - - local events_len idx event_type has_existing source_path owners_json tmp_payload - # Skip gracefully on malformed payload shapes; uploader remains best-effort. - events_len=$(jq '.events | if type=="array" then length else 0 end' "$payload_file" 2>/dev/null || echo 0) - if ! [[ "$events_len" =~ ^[0-9]+$ ]]; then - return 0 - fi - - for ((idx = 0; idx < events_len; idx++)); do - event_type=$(jq -r --argjson idx "$idx" '.events[$idx].type // ""' "$payload_file" 2>/dev/null || true) - # Spans are intentionally not enriched with CODEOWNERS metadata. - [[ "$event_type" == "span" ]] && continue - ((++CO_EVENTS_SCANNED)) - - has_existing=$(jq -r --argjson idx "$idx" 'if (.events[$idx].content.meta | type) == "object" and (.events[$idx].content.meta | has("test.codeowners")) then "1" else "0" end' "$payload_file" 2>/dev/null || echo "0") - if [[ "$has_existing" == "1" ]]; then - ((++CO_EVENTS_SKIPPED_EXISTING)) - [[ "$DEBUG" == "1" ]] && dbg "codeowners: skip existing tag at event[$idx]" - continue - fi - - source_path=$(jq -r --argjson idx "$idx" '.events[$idx].content.meta["test.source.file"] // .events[$idx].content.meta["test.source.path"] // .events[$idx].content.meta["source.file"] // .events[$idx].content.meta["source.path"] // .events[$idx].content.source.file // .events[$idx].content.source.path // ""' "$payload_file" 2>/dev/null || true) - if [[ -z "$source_path" ]]; then - ((++CO_EVENTS_SKIPPED_MISSING_SOURCE)) - [[ "$DEBUG" == "1" ]] && dbg "codeowners: skip missing source at event[$idx]" - continue - fi - - owners_json=$(resolve_codeowners_json_for_source "$source_path") - if [[ -z "$owners_json" ]]; then - ((++CO_EVENTS_SKIPPED_UNMATCHED)) - [[ "$DEBUG" == "1" ]] && dbg "codeowners: skip unmatched source '$source_path' at event[$idx]" - continue - fi - - tmp_payload=$(mktemp "$TMP_PAYLOAD_DIR/codeowners_payload.XXXXXX" 2>/dev/null || true) - if [[ -z "$tmp_payload" ]]; then - ((++CO_EVENTS_SKIPPED_ERRORS)) - [[ "$DEBUG" == "1" ]] && dbg "codeowners: skip internal error creating temp payload at event[$idx]" - continue - fi - if jq --arg owners "$owners_json" --argjson idx "$idx" ' - .events[$idx].content = (.events[$idx].content // {}) - | .events[$idx].content.meta = ((.events[$idx].content.meta // {}) | .["test.codeowners"] = $owners) - ' "$payload_file" > "$tmp_payload"; then - # Atomic replacement prevents partially-written payload files. - mv "$tmp_payload" "$payload_file" - ((++CO_EVENTS_ENRICHED)) - [[ "$DEBUG" == "1" ]] && dbg "codeowners: assigned owners '$owners_json' at event[$idx]" - else - rm -f "$tmp_payload" 2>/dev/null || true - ((++CO_EVENTS_SKIPPED_ERRORS)) - [[ "$DEBUG" == "1" ]] && dbg "codeowners: skip jq update failure at event[$idx]" - fi - done - - if [[ "$DEBUG" == "1" ]]; then - dbg "codeowners: scanned=$CO_EVENTS_SCANNED enriched=$CO_EVENTS_ENRICHED skipped_existing=$CO_EVENTS_SKIPPED_EXISTING skipped_missing_source=$CO_EVENTS_SKIPPED_MISSING_SOURCE skipped_unmatched=$CO_EVENTS_SKIPPED_UNMATCHED skipped_errors=$CO_EVENTS_SKIPPED_ERRORS" - fi -}} - -# Build common Datadog headers, optionally deriving values from payload metadata["*"]. -build_common_headers() {{ - local payload_file="${{1:-}}" - local lang="$HEADER_LANG_DEFAULT" - local lang_version="$HEADER_LANG_VERSION_DEFAULT" - local lang_interpreter="$HEADER_LANG_INTERPRETER_DEFAULT" - local tracer_version="$HEADER_TRACER_VERSION_DEFAULT" - - if (( JQ_AVAILABLE == 1 )) && [[ -n "$payload_file" && -f "$payload_file" ]]; then - local meta_values meta_lang meta_tracer meta_lang_version meta_lang_interpreter - meta_values=$(jq -r ' - [ - .metadata["*"]["language"] // "", - .metadata["*"]["library_version"] // "", - (.metadata["*"]["language_version"] // .metadata["*"]["runtime_version"] // ""), - (.metadata["*"]["language_interpreter"] // .metadata["*"]["runtime_name"] // "") - ] | @tsv - ' "$payload_file" 2>/dev/null || true) - if [[ -n "$meta_values" ]]; then - IFS=$'\t' read -r meta_lang meta_tracer meta_lang_version meta_lang_interpreter <<< "$meta_values" - [[ -n "$meta_lang" ]] && lang="$meta_lang" - [[ -n "$meta_tracer" ]] && tracer_version="$meta_tracer" - [[ -n "$meta_lang_version" ]] && lang_version="$meta_lang_version" - [[ -n "$meta_lang_interpreter" ]] && lang_interpreter="$meta_lang_interpreter" - fi - fi - - COMMON_HDRS=( - -H "Datadog-Meta-Lang: $lang" - -H "Datadog-Meta-Lang-Version: $lang_version" - -H "Datadog-Meta-Lang-Interpreter: $lang_interpreter" - -H "Datadog-Meta-Tracer-Version: $tracer_version" - -H "Accept: application/json" - ) -}} - -# Execute curl in agentless mode while sending DD-API-KEY via stdin (`-H @-`). -# This avoids exposing raw credentials in process arguments. -curl_agentless() {{ - if [[ -z "${{DD_API_KEY:-}}" ]]; then - return 2 - fi - printf 'DD-API-KEY: %s\n' "$DD_API_KEY" | curl "$@" -H @- -}} - -# Optional check: verify fetch-time API key fingerprint matches uploader API key. -API_KEY_FINGERPRINT="" -if (( JQ_AVAILABLE == 1 )) && [[ -n "$CONTEXT_JSON" && -f "$CONTEXT_JSON" ]]; then - API_KEY_FINGERPRINT=$(jq -r '."topt.api_key_fingerprint" // empty' "$CONTEXT_JSON" 2>/dev/null || true) -fi -if [[ -n "$API_KEY_FINGERPRINT" ]]; then - if (( AGENTLESS == 1 )); then - # Compare fetch-time and upload-time credentials without exposing raw keys. - local_fp=$(fnv1a_32 "$DD_API_KEY") - if [[ -n "$local_fp" && "$local_fp" != "$API_KEY_FINGERPRINT" ]]; then - log "warning: DD_API_KEY mismatch between fetch and uploader" - else - dbg "DD_API_KEY fingerprint match" - fi - else - # EVP mode does not require DD_API_KEY for upload requests. - log "warning: DD_API_KEY fingerprint present but uploader running in EVP mode; check skipped" - fi -elif [[ -n "$CONTEXT_JSON" && -f "$CONTEXT_JSON" && "$JQ_AVAILABLE" != "1" ]]; then - dbg "api key fingerprint check skipped: jq not available" -fi - -enrich_with_context() {{ - local infile="$1"; local tmpfile="$2" - dbg "enrich_with_context: infile='$infile' outfile='$tmpfile' ctx='${{CONTEXT_JSON:-}}' jq=$JQ_AVAILABLE" - if (( JQ_AVAILABLE == 0 )); then - # No jq means no structural merge; forward original payload unchanged. - cp "$infile" "$tmpfile" - return 0 - fi - local ctx_file="$CONTEXT_JSON" - local cleanup_ctx="" - if [[ -z "$ctx_file" || ! -f "$ctx_file" ]]; then - # Missing context is non-fatal: use empty object so enrichment still - # normalizes metadata shape without injecting context tags. - ctx_file="$(mktemp "$TMP_PAYLOAD_DIR/context.XXXXXX" 2>/dev/null || true)" - if [[ -z "$ctx_file" ]]; then - cp "$infile" "$tmpfile" - return 0 - fi - echo '{}' > "$ctx_file" - cleanup_ctx=1 - fi - jq --slurpfile ctx "$ctx_file" \ - --arg runtime_id "$RUNTIME_ID" \ - --arg rules_version "$RULES_VERSION" \ - --arg language_fallback "bazel" ' - def ctx_val($k): $ctx[0][$k]; - def ctx_str($k): (ctx_val($k) | if type=="string" and length>0 then . else null end); - def ctx_runtime_id: (ctx_str("runtime-id") // ctx_str("runtime.id") // ctx_str("runtime_id")); - def ctx_language: (ctx_str("language") // ctx_str("runtime.name") // ctx_str("runtime_name")); - def ctx_env: ctx_str("env"); - def ctx_filtered: ($ctx[0] | with_entries(select(.key != "topt.api_key_fingerprint"))); - def meta_star: (.metadata["*"] | if type=="object" then . else {} end); - def runtime_id: (meta_star["runtime-id"] // ctx_runtime_id // $runtime_id); - def language: (meta_star["language"] // ctx_language // $language_fallback); - def library_version: (meta_star["library_version"] // $rules_version); - def env: (meta_star["env"] // ctx_env); - .metadata = (.metadata // {}) - | .metadata["*"] = ( - { "runtime-id": runtime_id, "language": language, "library_version": library_version } - + (if (env|type) == "string" then { "env": env } else {} end) - ) - | .metadata = ( - { "*": .metadata["*"] } - + (if (.metadata["test"]? != null) then { "test": .metadata["test"] } else {} end) - + (if (.metadata["test_suite_end"]? != null) then { "test_suite_end": .metadata["test_suite_end"] } else {} end) - + (if (.metadata["test_module_end"]? != null) then { "test_module_end": .metadata["test_module_end"] } else {} end) - + (if (.metadata["test_session_end"]? != null) then { "test_session_end": .metadata["test_session_end"] } else {} end) - ) - | (if .events then - .events |= map( - if (.type? == "span") then . - else - ( - .content = (.content // {}) - | .content.meta = (if (.content.meta|type) == "object" then .content.meta else {} end) - | .content.metrics = (if (.content.metrics|type) == "object" then .content.metrics else {} end) - | reduce (ctx_filtered | to_entries[]) as $e (.; - if ($e.value|type) == "number" then - .content.metrics[$e.key] = $e.value - elif ($e.value|type) == "string" then - .content.meta[$e.key] = $e.value - else - .content.meta[$e.key] = ($e.value|tostring) - end - ) - ) - end - ) - else . - end) - ' "$infile" > "$tmpfile" - # CODEOWNERS enrichment is applied after metadata/context merge so source-path - # detection can leverage normalized event structure. - inject_codeowners_tags "$tmpfile" - if [[ -n "$cleanup_ctx" ]]; then - rm -f "$ctx_file" 2>/dev/null || true - fi -}} - -# Emit basic startTime statistics (ms) for debugging when jq is available. -log_start_time_stats() {{ - local file="$1" - if (( JQ_AVAILABLE == 0 )); then - dbg "startTime stats skipped: jq not available" - return 0 - fi - local times - # Prefer startTime; fall back to start if startTime is absent - times=$(jq -r '.. | objects | (.startTime? // .start?) | select(type=="number")' "$file" 2>/dev/null || true) - if [[ -z "$times" ]]; then - dbg "startTime stats: no startTime fields found in $file" - return 0 - fi - local min max - read min max < <(echo "$times" | awk 'NR==1{{min=$1;max=$1}} {{if($1max)max=$1}} END{{print min,max}}') - local now_ms - now_ms=$(( $(date +%s) * 1000 )) - dbg "startTime/ms range for $file: min=$min max=$max now=$now_ms" -}} - -# Check if file matches prefix filter (when enabled) -matches_filter() {{ - local file="$1" - local expected_prefix="$2" - if [[ "$FILTER_PREFIX" == "1" ]]; then - local basename - basename=$(basename "$file") - [[ "$basename" == "$expected_prefix"* ]] - else - return 0 # No filtering, accept all - fi -}} - -# Delete file unless KEEP_PAYLOADS is set -cleanup_file() {{ - local file="$1" - if [[ "$KEEP_PAYLOADS" != "1" ]]; then - # Some runfiles can be read-only; best-effort cleanup keeps uploads resilient. - if ! rm -f "$file" 2>/dev/null; then - chmod u+w "$file" 2>/dev/null || true - rm -f "$file" 2>/dev/null || true - fi - else - dbg "keeping payload (KEEP_PAYLOADS=1): $file" - fi -}} - -validate_payload() {{ - local file="$1" - if [[ -z "$SCHEMA_JSON" || ! -f "$SCHEMA_JSON" ]]; then - # Validation is best-effort and must never block uploads by default. - dbg "schema validation skipped: schema not available" - return 0 - fi - if [[ -z "$SCHEMA_VALIDATOR" || ! -f "$SCHEMA_VALIDATOR" ]]; then - dbg "schema validation skipped: validator not available" - return 0 - fi - if ! command -v python3 >/dev/null 2>&1; then - dbg "schema validation skipped: python3 not available" - return 0 - fi - dbg "schema validate: python3 $SCHEMA_VALIDATOR $SCHEMA_JSON $file" - if ! python3 "$SCHEMA_VALIDATOR" "$SCHEMA_JSON" "$file"; then - # Keep warning-only behavior so schema drift does not drop payloads. - log "warning: schema validation failed for payload: $file" - fi - return 0 -}} - -# Track upload failures globally -UPLOAD_FAILURES=0 - -upload_single_test() {{ - local file="$1" - local body resp payload_file gz http rc - # Use a temp file to avoid collisions when multiple uploads run in parallel. - body="$(mktemp "$TMP_PAYLOAD_DIR/test_payload.XXXXXX" 2>/dev/null || true)" - if [[ -z "$body" ]]; then - dbg "upload_single_test: failed to create temp file" - return 1 - fi - enrich_with_context "$file" "$body" - validate_payload "$body" - build_common_headers "$body" - dbg "upload_single_test: posting '$file' (body '$body')" - if [[ "$DEBUG" == "1" ]]; then - local gzip_note="" - if [[ "$GZIP_PAYLOADS" == "1" ]]; then - gzip_note="; Content-Encoding=gzip" - fi - echo "[dd-uploader][dbg] payload content (enriched) for '$file':" >&2 - cat "$body" >&2 - echo "" >&2 - log_start_time_stats "$body" - dbg "headers: Content-Type=application/json${gzip_note}" - fi - - payload_file="$body" - gz="" - if [[ "$GZIP_PAYLOADS" == "1" ]]; then - # Compress enriched payload, but gracefully fall back to plain JSON if - # gzip is unavailable/fails on the host. - gz="$body.gz" - if gzip -c "$body" > "$gz"; then - payload_file="$gz" - else - log "warning: gzip failed; sending uncompressed payload" - gz="" - fi - fi - - resp="$(mktemp "$TMP_PAYLOAD_DIR/test_resp.XXXXXX" 2>/dev/null || true)" - if [[ -z "$resp" ]]; then - dbg "upload_single_test: failed to create response temp file" - rm -f "$body" "$gz" 2>/dev/null || true - return 1 - fi - local ce_hdr=() - if [[ "$payload_file" != "$body" ]]; then - # Signal compressed body only when gzip output is actually used. - ce_hdr=(-H "Content-Encoding: gzip") - fi - if [[ "$DEBUG" == "1" ]]; then - dbg "request: POST $TEST_URL" - dbg_headers "common" "${COMMON_HDRS[@]}" - if (( AGENTLESS == 0 )); then - dbg_headers "evp" "${TEST_EVP[@]}" - fi - if [[ "$payload_file" != "$body" ]]; then - dbg "header[content-encoding]: Content-Encoding: gzip" - fi - fi - if (( AGENTLESS == 1 )); then - http=$(curl_agentless -f -sS --connect-timeout 10 --max-time 60 "${{CURL_RETRY_FLAGS[@]}}" \\ - -X POST "${{TEST_URL}}" "${{COMMON_HDRS[@]}}" "${{ce_hdr[@]+${{ce_hdr[@]}}}}" -H "Content-Type: application/json" --data-binary @"${{payload_file}}" -o "$resp" -w "%{{http_code}}") - else - http=$(curl -f -sS --connect-timeout 10 --max-time 60 "${{CURL_RETRY_FLAGS[@]}}" \\ - -X POST "${{TEST_URL}}" "${{COMMON_HDRS[@]}}" "${{TEST_EVP[@]}}" "${{ce_hdr[@]+${{ce_hdr[@]}}}}" -H "Content-Type: application/json" --data-binary @"${{payload_file}}" -o "$resp" -w "%{{http_code}}") - fi - rc=$? - http="${{http:-000}}" - if [[ "$DEBUG" == "1" || $rc -ne 0 || "$http" -lt 200 || "$http" -ge 300 ]]; then - dbg "upload_single_test: HTTP $http (rc=$rc)" - if [[ -s "$resp" ]]; then - dbg "upload_single_test response: $(head -c 2000 "$resp")" - fi - fi - rm -f "$resp" "$body" "$gz" 2>/dev/null || true - # Cleanup happens before return to avoid temp-file buildup on retries/runs. - if [[ $rc -ne 0 || "$http" -lt 200 || "$http" -ge 300 ]]; then - return 1 - fi - return 0 -}} - -upload_single_coverage() {{ - local file="$1" - # Create event.json for multipart - local eventjson resp http rc - # Use a temp file for multipart metadata to avoid leaking into runfiles. - eventjson="$(mktemp "$TMP_PAYLOAD_DIR/coverage_event.XXXXXX" 2>/dev/null || true)" - if [[ -z "$eventjson" ]]; then - dbg "upload_single_coverage: failed to create temp file" - return 1 - fi - echo '{{"dummy":true}}' > "$eventjson" - build_common_headers "" - dbg "upload_single_coverage: posting '$file'" - resp="$(mktemp "$TMP_PAYLOAD_DIR/coverage_resp.XXXXXX" 2>/dev/null || true)" - if [[ -z "$resp" ]]; then - dbg "upload_single_coverage: failed to create response temp file" - rm -f "$eventjson" 2>/dev/null || true - return 1 - fi - if [[ "$DEBUG" == "1" ]]; then - dbg "request: POST $COV_URL" - dbg_headers "common" "${COMMON_HDRS[@]}" - if (( AGENTLESS == 0 )); then - dbg_headers "evp" "${COV_EVP[@]}" - fi - dbg "headers: multipart/form-data (event + coveragex)" - fi - if (( AGENTLESS == 1 )); then - http=$(curl_agentless -f -sS --connect-timeout 10 --max-time 60 "${{CURL_RETRY_FLAGS[@]}}" \\ - -X POST "${{COV_URL}}" "${{COMMON_HDRS[@]}}" \\ - -F "event=@${{eventjson}};type=application/json;filename=fileevent.json" \\ - -F "coveragex=@${{file}};type=application/json;filename=filecoveragex.json" -o "$resp" -w "%{{http_code}}") - else - http=$(curl -f -sS --connect-timeout 10 --max-time 60 "${{CURL_RETRY_FLAGS[@]}}" \\ - -X POST "${{COV_URL}}" "${{COMMON_HDRS[@]}}" "${{COV_EVP[@]}}" \\ - -F "event=@${{eventjson}};type=application/json;filename=fileevent.json" \\ - -F "coveragex=@${{file}};type=application/json;filename=filecoveragex.json" -o "$resp" -w "%{{http_code}}") - fi - rc=$? - http="${{http:-000}}" - if [[ "$DEBUG" == "1" || $rc -ne 0 || "$http" -lt 200 || "$http" -ge 300 ]]; then - dbg "upload_single_coverage: HTTP $http (rc=$rc)" - if [[ -s "$resp" ]]; then - dbg "upload_single_coverage response: $(head -c 2000 "$resp")" - fi - fi - rm -f "$resp" "$eventjson" 2>/dev/null || true - if [[ $rc -ne 0 || "$http" -lt 200 || "$http" -ge 300 ]]; then - return 1 - fi - return 0 -}} - -upload_all_tests() {{ - local total=0 - local failed=0 - local skipped=0 - # Iterate the cached test.outputs list to avoid rescanning the filesystem. - while IFS= read -r outputs_dir; do - [[ -z "$outputs_dir" ]] && continue - local tests_dir="$outputs_dir/payloads/tests" - [[ -d "$tests_dir" ]] || continue - - for f in "$tests_dir"/*.json; do - [[ -f "$f" ]] || continue - # Skip files not matching prefix filter (when enabled) - if ! matches_filter "$f" "span_events_"; then - dbg "skipping (prefix filter): $f" - ((++skipped)) - continue - fi - if upload_single_test "$f"; then - log "uploaded test payload: $f" - cleanup_file "$f" - ((++total)) - else - # Keep uploading subsequent files to maximize successful delivery - # even when one payload is malformed or temporarily rejected. - log "warning: failed to upload $f" - ((++failed)) - ((++UPLOAD_FAILURES)) - fi - done - done < <(echo "$TEST_OUTPUTS_CACHE") - log "uploaded $total test payloads" - if (( failed > 0 )); then - log "warning: $failed test payloads failed to upload" - fi - if (( skipped > 0 )); then - dbg "skipped $skipped files (prefix filter)" - fi -}} - -upload_all_coverage() {{ - local total=0 - local failed=0 - local skipped=0 - # Iterate the cached test.outputs list to avoid rescanning the filesystem. - while IFS= read -r outputs_dir; do - [[ -z "$outputs_dir" ]] && continue - local cov_dir="$outputs_dir/payloads/coverage" - [[ -d "$cov_dir" ]] || continue - - for f in "$cov_dir"/*.json; do - [[ -f "$f" ]] || continue - # Skip files not matching prefix filter (when enabled) - if ! matches_filter "$f" "coverage_"; then - dbg "skipping (prefix filter): $f" - ((++skipped)) - continue - fi - if upload_single_coverage "$f"; then - log "uploaded coverage payload: $f" - cleanup_file "$f" - ((++total)) - else - # Coverage failures are tracked but non-fatal per-file; final - # exit code reflects aggregate failure count after both passes. - log "warning: failed to upload $f" - ((++failed)) - ((++UPLOAD_FAILURES)) - fi - done - done < <(echo "$TEST_OUTPUTS_CACHE") - log "uploaded $total coverage payloads" - if (( failed > 0 )); then - log "warning: $failed coverage payloads failed to upload" - fi - if (( skipped > 0 )); then - dbg "skipped $skipped files (prefix filter)" - fi -}} - -upload_all_tests -upload_all_coverage - -# Exit with appropriate code based on upload results -if (( UPLOAD_FAILURES > 0 )); then - # Non-zero signals partial/total upload failure to CI orchestration. - log "done with $UPLOAD_FAILURES upload failures" - exit 1 -else - # Zero means either complete success or intentional no-op path above. - log "done" - exit 0 -fi -""" +UPLOADER_BASH_TEMPLATE_FILE = "uploader_bash_runtime.sh.tpl" diff --git a/tools/core/uploader_batch_runtime.bat.tpl b/tools/core/uploader_batch_runtime.bat.tpl new file mode 100644 index 00000000..43fbc1ef --- /dev/null +++ b/tools/core/uploader_batch_runtime.bat.tpl @@ -0,0 +1,5 @@ +@echo off +setlocal +set "SCRIPT_DIR=%~dp0" +powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%SCRIPT_DIR%__DDTPL_PS_NAME__" +exit /b %ERRORLEVEL% diff --git a/tools/core/uploader_batch_template.bzl b/tools/core/uploader_batch_template.bzl index dc752432..0d6050a7 100644 --- a/tools/core/uploader_batch_template.bzl +++ b/tools/core/uploader_batch_template.bzl @@ -1,8 +1,3 @@ -"""Batch launcher template for dd_payload_uploader.""" +"""Metadata for the standalone Batch uploader launcher template.""" -UPLOADER_BATCH_TEMPLATE = """@echo off -setlocal -set "SCRIPT_DIR=%~dp0" -powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%SCRIPT_DIR%{ps_name}" -exit /b %ERRORLEVEL% -""" +UPLOADER_BATCH_TEMPLATE_FILE = "uploader_batch_runtime.bat.tpl" diff --git a/tools/core/uploader_powershell_runtime.ps1.tpl b/tools/core/uploader_powershell_runtime.ps1.tpl new file mode 100644 index 00000000..c99556dd --- /dev/null +++ b/tools/core/uploader_powershell_runtime.ps1.tpl @@ -0,0 +1,1811 @@ +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +# Resolve runfile path for context.json lookup +# Since `bazel run` does NOT set TEST_SRCDIR, we use RUNFILES_DIR or RUNFILES_MANIFEST_FILE +function Resolve-Runfile { + param([string]$InputRloc) + + $Rloc = $InputRloc + $Rloc = $Rloc.Replace([char]92, [char]47) + # Normalize relative prefixes that can appear in bzlmod runfile paths + if ($Rloc.StartsWith("./")) { $Rloc = $Rloc.Substring(2) } + while ($Rloc.StartsWith("../")) { $Rloc = $Rloc.Substring(3) } + # Defensive guard: runfile labels must remain repository-relative. + # We reject absolute/drive-qualified and parent-traversal paths so lookups + # cannot accidentally resolve outside runfiles roots. + if ([string]::IsNullOrEmpty($Rloc) -or $Rloc.StartsWith("/") -or ($Rloc -match '^[A-Za-z]:/') -or $Rloc -eq ".." -or $Rloc.EndsWith("/..") -or $Rloc.Contains("/../")) { + Dbg "Resolve-Runfile rejected suspicious runfile label '$InputRloc' (normalized='$Rloc')" + return $null + } + + $candidates = @($Rloc) + if ($Rloc.StartsWith("external/")) { + $candidates += $Rloc.Substring(9) + } else { + # Try the external/ prefix when short_path omits it under bzlmod. + $candidates += "external/$Rloc" + } + if (-not $Rloc.StartsWith("_main/")) { + $candidates += "_main/$Rloc" + } + Dbg "Resolve-Runfile input='$InputRloc' normalized='$Rloc' candidates='$($candidates -join ',')'" + + if ($env:RUNFILES_DIR) { + $rfExists = Test-Path -LiteralPath $env:RUNFILES_DIR + Dbg "Resolve-Runfile RUNFILES_DIR='$($env:RUNFILES_DIR)' exists=$rfExists" + } else { + Dbg "Resolve-Runfile RUNFILES_DIR=" + } + + $manifest = $null + if ($env:RUNFILES_MANIFEST_FILE) { + $mfExists = Test-Path -LiteralPath $env:RUNFILES_MANIFEST_FILE + Dbg "Resolve-Runfile RUNFILES_MANIFEST_FILE='$($env:RUNFILES_MANIFEST_FILE)' exists=$mfExists" + if ($mfExists) { + $manifest = Get-Content -LiteralPath $env:RUNFILES_MANIFEST_FILE -Encoding UTF8 + Dbg "Resolve-Runfile manifest entries loaded=$($manifest.Count)" + } + } else { + Dbg "Resolve-Runfile RUNFILES_MANIFEST_FILE=" + } + + foreach ($cand in $candidates) { + Dbg "Resolve-Runfile trying candidate '$cand'" + # Try RUNFILES_DIR first + if ($env:RUNFILES_DIR) { + $candidate = Join-Path $env:RUNFILES_DIR $cand + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + Dbg "Resolve-Runfile hit RUNFILES_DIR -> '$candidate'" + return $candidate + } + } + + # Try local runfiles directory fallbacks when RUNFILES_DIR is unavailable. + # Depending on launcher/platform we may see: + # -