diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0e26911aa0b..d902dc3b42d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,6 @@ include: - local: ".gitlab/one-pipeline.locked.yml" + - local: ".gitlab/windows-tests.yml" - local: ".gitlab/benchmarks.yml" - local: ".gitlab/exploration-tests.yml" - local: ".gitlab/ci-visibility-tests.yml" @@ -39,6 +40,7 @@ stages: - benchmarks - tests - tests-arm64 + - tests-windows - test-summary - exploration-tests - ci-visibility-tests diff --git a/.gitlab/windows-tests.yml b/.gitlab/windows-tests.yml new file mode 100644 index 00000000000..bbdc85caded --- /dev/null +++ b/.gitlab/windows-tests.yml @@ -0,0 +1,269 @@ +variables: + WINDOWS_BUILD_IMAGE: "registry.ddbuild.io/ci/dd-trace-java/dd-trace-java-windows-docker-build:prototype-alexeyk-gitlab-windows-tests" + +.windows_gradle_cache_paths: &windows_gradle_cache_paths + - .gradle/wrapper/ + - .gradle/caches/ + - .gradle/notifications/ + +build-windows-ci-image: + stage: build + timeout: 2h + tags: [ "windows-v2:2025" ] + when: manual + allow_failure: true + hooks: + pre_get_sources_script: + - git config --system core.longpaths true + - git config --global core.autocrlf false + script: + - | + . .gitlab/windows/ci-common.ps1 + + $image = Get-WindowsCiImage + Write-Output "Target image: $image" + + $buildArguments = @( + "build", + "--file", ".gitlab/windows/image/Dockerfile", + "--tag", $image + ) + + Invoke-Native docker @("pull", "--quiet", $image) + if ($LASTEXITCODE -eq 0) { + $buildArguments += @("--cache-from", $image) + } else { + Write-Output "No existing prototype image found; building without a registry layer cache." + } + + $buildArguments += ".gitlab/windows/image" + Invoke-Native docker $buildArguments + if ($LASTEXITCODE -ne 0) { throw "docker build failed" } + + Invoke-Native docker @("push", $image) + if ($LASTEXITCODE -ne 0) { throw "docker push $image failed" } + id_tokens: + CI_IDENTITIES_GITLAB_ID_TOKEN: + aud: ci-identities + +test-base-windows: + stage: tests-windows + timeout: 2h + tags: [ "windows-v2:2025" ] + hooks: + pre_get_sources_script: + - git config --system core.longpaths true + - git config --global core.autocrlf false + needs: + - job: build + artifacts: false + # Reads the prototype image from registry.ddbuild.io, so it needs the same CI + # identity as the job that publishes it. + id_tokens: + CI_IDENTITIES_GITLAB_ID_TOKEN: + aud: ci-identities + variables: + GIT_SUBMODULE_STRATEGY: normal + GIT_SUBMODULE_DEPTH: 1 + GRADLE_TARGET: ":baseTest" + GRADLE_WORKERS: "4" + CACHE_TYPE: "base" + # Feature branches only read the shared dependency seed; protected refs + # refresh it (overridden in rules below). + WINDOWS_SEED_CACHE_POLICY: pull + parallel: + matrix: + - testJvm: [ "21" ] + CI_SPLIT: [ "1/4", "2/4", "3/4", "4/4" ] + rules: + - if: '$POPULATE_CACHE' + when: never + - if: '$CI_COMMIT_BRANCH =~ /^mq-working-branch-/' + when: on_success + allow_failure: true + variables: + WINDOWS_SEED_CACHE_POLICY: pull-push + - if: '$CI_COMMIT_BRANCH == "master"' + when: on_success + allow_failure: true + variables: + WINDOWS_SEED_CACHE_POLICY: pull-push + - if: '$CI_COMMIT_BRANCH' + when: manual + allow_failure: true + cache: + # Shared dependency seed. Only protected refs write it, so a new feature + # branch does not resolve every dependency from scratch on all four + # partitions. unprotect:true is what makes the seed readable from + # unprotected refs; the pull-only policy keeps them from poisoning it. + - key: + files: + - gradle/wrapper/gradle-wrapper.properties + - settings.gradle.kts + prefix: "windows-gradle-seed-$CACHE_TYPE-$CI_NODE_INDEX" + paths: *windows_gradle_cache_paths + policy: $WINDOWS_SEED_CACHE_POLICY + unprotect: true + # Per-branch, per-partition cache. Restored after the seed, so it wins. + - key: + files: + - gradle/wrapper/gradle-wrapper.properties + - settings.gradle.kts + prefix: "windows-gradle-$CACHE_TYPE-$CI_COMMIT_REF_SLUG-$CI_NODE_INDEX" + paths: *windows_gradle_cache_paths + policy: pull-push + script: + - | + . .gitlab/windows/ci-common.ps1 + + $image = Get-WindowsCiImage + Write-Output "Expected Windows test image: $image" + Invoke-Native docker @("pull", "--quiet", $image) + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Could not pull Windows test image $image." + Write-Host "Manually run build-windows-ci-image, wait for it to finish, then retry this job." + exit 1 + } + + $diagnosticsDir = Join-Path $env:CI_PROJECT_DIR ".tmp" + New-Item -ItemType Directory -Force -Path $diagnosticsDir, ".gradle" | Out-Null + + $containerName = "dd-trace-java-windows-$($env:CI_JOB_ID)" + $dockerArguments = @( + "run", "--rm", + "--name", $containerName, + "--memory", "20g", + # Windows reports the host's logical processors unless the count is + # capped, which makes thread-pool-sized assertions non-deterministic. + "--cpu-count", "4", + "--workdir", "C:\work", + "--mount", "type=bind,source=$($env:CI_PROJECT_DIR),target=C:\work", + "--mount", "type=bind,source=$diagnosticsDir,target=C:\tmp", + "--env", "CI=true", + "--env", "GITLAB_CI=true", + "--env", "CI_JOB_ID=$($env:CI_JOB_ID)", + "--env", "CI_PIPELINE_ID=$($env:CI_PIPELINE_ID)", + "--env", "CI_COMMIT_SHA=$($env:CI_COMMIT_SHA)", + "--env", "CI_COMMIT_BRANCH=$($env:CI_COMMIT_BRANCH)", + "--env", "CI_SPLIT=$($env:CI_SPLIT)", + "--env", "GRADLE_TARGET=$($env:GRADLE_TARGET)", + "--env", "GRADLE_WORKERS=$($env:GRADLE_WORKERS)", + "--env", "testJvm=$($env:testJvm)", + "--env", "MAVEN_REPOSITORY_PROXY=$($env:MAVEN_REPOSITORY_PROXY)", + "--env", "GRADLE_PLUGIN_PROXY=$($env:GRADLE_PLUGIN_PROXY)", + "--env", "MASS_READ_URL=$($env:MASS_READ_URL)", + # Parity with .test_job_common on Linux: both runtimes size pools from + # availableProcessors(), which is unreliable in containers. + "--env", "JAVA_TOOL_OPTIONS=-XX:ActiveProcessorCount=4", + "--env", "RUNTIME_AVAILABLE_PROCESSORS_OVERRIDE=4", + "--env", "JETTY_AVAILABLE_PROCESSORS=4", + "--env", "TESTCONTAINERS_CHECKS_DISABLE=true", + "--env", "TESTCONTAINERS_RYUK_DISABLED=true", + "--env", "TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX=registry.ddbuild.io/images/mirror/", + $image, + "powershell", "-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-File", "C:\work\.gitlab\windows\run-base-tests.ps1" + ) + + Invoke-Native docker $dockerArguments + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + after_script: + - | + $containerName = "dd-trace-java-windows-$($env:CI_JOB_ID)" + docker rm --force $containerName 2>$null + exit 0 + artifacts: + when: always + paths: + - workspace/**/build/test-results/** + - workspace/**/build/reports/tests/** + - buildSrc/build/test-results/** + - buildSrc/build/reports/tests/** + - .gradle/daemon/*/*.out.log + - .gradle/workers/hs_err_pid*.log + - .tmp/** + reports: + junit: + - workspace/**/build/test-results/**/*.xml + - buildSrc/build/test-results/**/*.xml + +# Keep the smoke and instrumentation rollout opt-in on every ref. Unlike their Linux +# counterparts, these jobs intentionally do not enable the APM Test Agent sidecar; +# cross-container Test Agent connectivity can be added as a separate step. +test-smoke-windows: + extends: test-base-windows + variables: + GRADLE_TARGET: "stageMainDist :smokeTest" + # Smoke tasks start additional JVMs and Docker processes, so leave one + # worker of headroom inside the 20 GiB Windows container. + GRADLE_WORKERS: "3" + CACHE_TYPE: "smoke" + parallel: + matrix: + - testJvm: [ "21" ] + CI_SPLIT: [ "1/8", "2/8", "3/8", "4/8", "5/8", "6/8", "7/8", "8/8" ] + rules: + - if: '$POPULATE_CACHE' + when: never + - if: '$CI_COMMIT_BRANCH =~ /^mq-working-branch-/' + when: manual + allow_failure: true + variables: + WINDOWS_SEED_CACHE_POLICY: pull-push + - if: '$CI_COMMIT_BRANCH == "master"' + when: manual + allow_failure: true + variables: + WINDOWS_SEED_CACHE_POLICY: pull-push + - when: manual + allow_failure: true + +test-inst-windows: + extends: test-base-windows + variables: + GRADLE_TARGET: ":instrumentationTest" + CACHE_TYPE: "inst" + parallel: + matrix: + - testJvm: [ "21" ] + CI_SPLIT: [ "1/8", "2/8", "3/8", "4/8", "5/8", "6/8", "7/8", "8/8" ] + rules: + - if: '$POPULATE_CACHE' + when: never + - if: '$CI_COMMIT_BRANCH =~ /^mq-working-branch-/' + when: manual + allow_failure: true + variables: + WINDOWS_SEED_CACHE_POLICY: pull-push + - if: '$CI_COMMIT_BRANCH == "master"' + when: manual + allow_failure: true + variables: + WINDOWS_SEED_CACHE_POLICY: pull-push + - when: manual + allow_failure: true + +test-inst-latest-windows: + extends: test-base-windows + variables: + GRADLE_TARGET: ":instrumentationLatestDepTest" + CACHE_TYPE: "latestdep" + parallel: + matrix: + - testJvm: [ "21" ] + CI_SPLIT: [ "1/6", "2/6", "3/6", "4/6", "5/6", "6/6" ] + rules: + - if: '$POPULATE_CACHE' + when: never + - if: '$CI_COMMIT_BRANCH =~ /^mq-working-branch-/' + when: manual + allow_failure: true + variables: + WINDOWS_SEED_CACHE_POLICY: pull-push + - if: '$CI_COMMIT_BRANCH == "master"' + when: manual + allow_failure: true + variables: + WINDOWS_SEED_CACHE_POLICY: pull-push + - when: manual + allow_failure: true diff --git a/.gitlab/windows/README.md b/.gitlab/windows/README.md new file mode 100644 index 00000000000..f8ed2f3730e --- /dev/null +++ b/.gitlab/windows/README.md @@ -0,0 +1,60 @@ +# Windows GitLab prototype + +This directory contains an experimental Windows test job and its repo-local CI image. +The image contains MinGit and the Temurin 8, 11, 17, 21, and 25 JDK toolchains used by +the Gradle build. JDK 21 is the default daemon and test JVM. The base test scope runs +`:baseTest` on Java 21, split into the same four partitions as the existing `test_base` +job. Three fully manual scopes run `stageMainDist :smokeTest` and `:instrumentationTest` +on Java 21 in eight partitions, plus `:instrumentationLatestDepTest` on Java 21 in six +partitions. Together they validate the execution model before moving the image to +`dd-trace-java-docker-build`. + +## Running the prototype + +1. Push the branch and open its GitLab pipeline. +2. Manually run `build-windows-ci-image`. +3. Wait for the image to be pushed to + `registry.ddbuild.io/ci/dd-trace-java/dd-trace-java-windows-docker-build:prototype-alexeyk-gitlab-windows-tests`. +4. Run or retry the four `test-base-windows` matrix jobs. +5. Trigger the desired `test-smoke-windows` partitions manually. Starting with `1/8` + provides a small plumbing check before running the other seven partitions. +6. Trigger the desired `test-inst-windows` partitions manually. Starting with `1/8` + provides a small plumbing check before running the other seven partitions. +7. Trigger the desired `test-inst-latest-windows` partitions manually. Starting with + `1/6` provides the equivalent plumbing check before running the other five partitions. + +The image producer always overwrites this single mutable prototype tag and uses the +previous image as its Docker layer cache. Test jobs explicitly pull the tag before use, +so a long-lived Windows runner does not reuse a stale local copy. + +The base test job is manual and non-blocking on feature branches. It runs automatically +but remains non-blocking on merge-queue branches and `master`. Smoke and instrumentation +jobs are manual and non-blocking on every ref during the initial rollout. + +## Updating the image + +Run `build-windows-ci-image` after changing anything under `image/`. The same prototype +tag is replaced after a successful build, which avoids accumulating per-experiment tags. +The test job fails with an instruction to run the producer when it cannot pull that tag. + +## Caching + +Test jobs read `.gradle/{wrapper,caches,notifications}` from a category-specific seed +cache that only protected refs write, then push a per-branch, per-partition cache on top. +A new feature branch therefore starts from the last protected-ref dependency set instead +of resolving everything separately in every partition. + +## Notes on Windows + +- All `docker` calls go through `Invoke-Native` in `ci-common.ps1`. PowerShell turns a + native command's stderr into a terminating error under `$ErrorActionPreference = 'Stop'`, + and docker writes progress and "manifest unknown" to stderr. +- The container gets `LongPathsEnabled` and `core.longpaths`; the deepest relocated build + outputs under `C:\work\workspace\...` exceed the 260-character `MAX_PATH` default. +- Temurin publishes no JDK 21 newer than `21.0.12+8` for `windowsservercore-ltsc2025`, + while 8, 11, 17, and 25 are current. Expect some failures on the Java 21 matrix to be + JDK-version artifacts rather than Windows-specific. +- The initial smoke and instrumentation jobs do not set `CI_USE_TEST_AGENT`. The Linux + Test Agent sidecar is not reachable from the explicitly launched Windows test container + without additional networking support; instrumentation tests still use their in-memory + writer for their primary assertions. diff --git a/.gitlab/windows/ci-common.ps1 b/.gitlab/windows/ci-common.ps1 new file mode 100644 index 00000000000..59dc8ee85d4 --- /dev/null +++ b/.gitlab/windows/ci-common.ps1 @@ -0,0 +1,33 @@ +# Shared helpers for the Windows CI jobs in .gitlab/windows-tests.yml. +# Dot-source this file at the top of a job script: . .gitlab/windows/ci-common.ps1 + +$ErrorActionPreference = "Stop" + +# Runs a native command with its stderr merged into stdout. +# +# PowerShell wraps a native command's stderr in ErrorRecord objects, and under +# $ErrorActionPreference = 'Stop' the first one becomes a terminating error. +# docker writes build progress, push progress and "manifest unknown" to stderr, +# so calling it directly would fail the job on paths that are expected to work. +# Callers must gate on $LASTEXITCODE, which this function leaves untouched. +# +# Arguments are passed as a single array rather than splatted, so tokens such as +# "--tag" reach the command instead of being bound as function parameters. +function Invoke-Native { + param( + [Parameter(Mandatory = $true)][string] $Command, + [string[]] $Arguments = @() + ) + + # Function-scoped, so the caller keeps 'Stop' for cmdlet errors. + $ErrorActionPreference = "Continue" + & $Command @Arguments 2>&1 | ForEach-Object { Write-Host "$_" } +} + +# Resolves the mutable image tag used by this personal prototype. +function Get-WindowsCiImage { + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_BUILD_IMAGE)) { + throw "WINDOWS_BUILD_IMAGE is not set" + } + return $env:WINDOWS_BUILD_IMAGE +} diff --git a/.gitlab/windows/image/Dockerfile b/.gitlab/windows/image/Dockerfile new file mode 100644 index 00000000000..3c6bb5ac375 --- /dev/null +++ b/.gitlab/windows/image/Dockerfile @@ -0,0 +1,63 @@ +# escape=` + +FROM eclipse-temurin:8-jdk-windowsservercore-ltsc2025@sha256:0ce93c7ea851ec5ea7d35393d106bd717697865b6111a681964f6eb7147f14c8 AS temurin8 +FROM eclipse-temurin:11-jdk-windowsservercore-ltsc2025@sha256:f58946540fe9ae7c685b9d2b40e58746b2332fa9bd8546ec4d2881143d9da99e AS temurin11 +FROM eclipse-temurin:17-jdk-windowsservercore-ltsc2025@sha256:aa7cfb793cd567a3bb2178600c7179249e6058f7a0b9b9e3b44be4cb12bde11b AS temurin17 +FROM eclipse-temurin:25-jdk-windowsservercore-ltsc2025@sha256:28a4b5421e4c03e4a86c900971fd835c1b343564a972da324bdabf666766952b AS temurin25 + +FROM eclipse-temurin:21-jdk-windowsservercore-ltsc2025@sha256:8828b43d3c6be114c39da1696c48d3b76ee46ab4bf08b5afeaf057174b60a9f5 + +SHELL ["powershell", "-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue';"] + +USER ContainerAdministrator + +COPY --from=temurin8 C:/openjdk-8 C:/openjdk-8 +COPY --from=temurin11 C:/openjdk-11 C:/openjdk-11 +COPY --from=temurin17 C:/openjdk-17 C:/openjdk-17 +COPY --from=temurin25 C:/openjdk-25 C:/openjdk-25 + +ENV JAVA_8_HOME="C:\openjdk-8" +ENV JAVA_11_HOME="C:\openjdk-11" +ENV JAVA_17_HOME="C:\openjdk-17" +ENV JAVA_21_HOME="C:\openjdk-21" +ENV JAVA_25_HOME="C:\openjdk-25" +ENV JAVA_HOME="C:\openjdk-21" + +ARG MINGIT_VERSION="2.55.0.5" +ARG MINGIT_RELEASE="v2.55.0.windows.5" +ARG MINGIT_SHA256="56d7b226b7693196cfc71fef26568f536c4a021ab6c37ff2db4287bed908e96e" + +# The classic Windows Docker builder consumes nested double quotes in shell-form +# RUN commands. Use PowerShell single-quoted strings and -f formatting here. +RUN $archive = 'C:\mingit.zip'; ` + $url = 'https://github.com/git-for-windows/git/releases/download/{0}/MinGit-{1}-64-bit.zip' -f $env:MINGIT_RELEASE, $env:MINGIT_VERSION; ` + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; ` + (New-Object System.Net.WebClient).DownloadFile($url, $archive); ` + $actual = (Get-FileHash -Path $archive -Algorithm SHA256).Hash.ToLowerInvariant(); ` + if ($actual -ne $env:MINGIT_SHA256) { throw ('MinGit checksum mismatch: expected {0}, got {1}' -f $env:MINGIT_SHA256, $actual) }; ` + Expand-Archive -Path $archive -DestinationPath C:\MinGit -Force; ` + Remove-Item $archive -Force + +ENV PATH="C:\MinGit\cmd;C:\MinGit\mingw64\bin;C:\openjdk-21\bin;C:\Windows\System32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0" + +# The longest source path in the repository is over 200 characters, and relocated +# build outputs under C:\work\workspace\... push past the 260-character MAX_PATH +# default. The runner sets core.longpaths on the host checkout; the container +# needs both the Win32 opt-in and its own git setting. +RUN Set-ItemProperty ` + -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' ` + -Name 'LongPathsEnabled' -Value 1 -Type DWord; ` + git config --system core.longpaths true + +# Native tools can write version information to stderr. Run them with non-terminating +# native error handling, capture their output, and gate each call on its exit code. +RUN $ErrorActionPreference = 'Continue'; ` + foreach ($javaHome in @($env:JAVA_8_HOME, $env:JAVA_11_HOME, $env:JAVA_17_HOME, $env:JAVA_21_HOME, $env:JAVA_25_HOME)) { ` + $java = Join-Path $javaHome 'bin\java.exe'; ` + $output = (& $java -version 2>&1 | Out-String).Trim(); ` + if ($LASTEXITCODE -ne 0) { throw ('java -version failed for {0}' -f $javaHome) }; ` + Write-Host ('{0} -> {1}' -f $javaHome, $output); ` + }; ` + $gitVersion = (git --version 2>&1 | Out-String).Trim(); ` + if ($LASTEXITCODE -ne 0) { throw 'git --version failed' }; ` + Write-Host $gitVersion diff --git a/.gitlab/windows/run-base-tests.ps1 b/.gitlab/windows/run-base-tests.ps1 new file mode 100644 index 00000000000..bdaef59c6f7 --- /dev/null +++ b/.gitlab/windows/run-base-tests.ps1 @@ -0,0 +1,94 @@ +$ErrorActionPreference = "Stop" + +Set-Location "C:\work" +git config --global --add safe.directory "C:/work" + +if ($env:CI_SPLIT -notmatch '^[1-9][0-9]*/[1-9][0-9]*$') { + throw "Expected CI_SPLIT in positive integer index/total form; got '$env:CI_SPLIT'" +} +if ([string]::IsNullOrWhiteSpace($env:GRADLE_TARGET)) { + throw "GRADLE_TARGET is required" +} +if ([string]::IsNullOrWhiteSpace($env:testJvm)) { + throw "testJvm is required" +} +if ($env:GRADLE_WORKERS -notmatch '^[1-9][0-9]*$') { + throw "Expected GRADLE_WORKERS to be a positive integer; got '$env:GRADLE_WORKERS'" +} + +$split = $env:CI_SPLIT.Split("/") +if ([int]$split[0] -gt [int]$split[1]) { + throw "Expected the CI_SPLIT index to be at most its total; got '$env:CI_SPLIT'" +} +$env:CI_NODE_INDEX = $split[0] +$env:CI_NODE_TOTAL = $split[1] + +$env:GRADLE_USER_HOME = "C:\work\.gradle" +$env:ORG_GRADLE_PROJECT_mavenRepositoryProxy = $env:MAVEN_REPOSITORY_PROXY +$env:ORG_GRADLE_PROJECT_gradlePluginProxy = $env:GRADLE_PLUGIN_PROXY + +$javaHomeVariables = (Get-ChildItem Env: | Where-Object Name -Match '^JAVA_[A-Z0-9_]+_HOME$' | Sort-Object Name).Name -Join ',' +# This file wins over the project gradle.properties, so org.gradle.jvmargs must +# repeat every flag the project file sets (currently -XX:MaxMetaspaceSize). +$gradleProperties = @( + "org.gradle.java.installations.auto-detect=false", + "org.gradle.java.installations.auto-download=false", + "org.gradle.java.installations.fromEnv=$javaHomeVariables", + "org.gradle.jvmargs=-Xms1g -Xmx4g -XX:MaxMetaspaceSize=1g -Djava.util.prefs.userRoot=C:/tmp/java-prefs -Ddatadog.forkedMinHeapSize=128M -Ddatadog.forkedMaxHeapSize=1024M -XX:ErrorFile=C:/tmp/hs_err_pid%p.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=C:/tmp" +) +New-Item -ItemType Directory -Force -Path $env:GRADLE_USER_HOME | Out-Null +$gradleProperties | Set-Content -Path (Join-Path $env:GRADLE_USER_HOME "gradle.properties") -Encoding ASCII + +# Route the Gradle distribution download through the MASS pull-through cache, the +# same way .gitlab-ci.yml does for every Linux job. The edit is reverted before the +# job ends because the GitLab cache key is derived from this file's contents. +$wrapperProperties = "gradle\wrapper\gradle-wrapper.properties" +$originalWrapperProperties = Get-Content -Path $wrapperProperties -Raw +if (-not [string]::IsNullOrWhiteSpace($env:MASS_READ_URL)) { + $massHost = ($env:MASS_READ_URL -replace '^https://', '').TrimEnd('/') + (Get-Content -Path $wrapperProperties) ` + -replace '^(distributionUrl=.*)services\.gradle\.org', "`$1$massHost/internal/artifact/services.gradle.org" ` + | Set-Content -Path $wrapperProperties -Encoding ASCII + Write-Output "Routing the Gradle distribution through $massHost" +} else { + Write-Warning "MASS_READ_URL is not set; downloading the Gradle distribution directly" +} + +try { + Write-Output "Running $env:GRADLE_TARGET on Java $env:testJvm, partition $env:CI_SPLIT" + java --version + git --version + & .\gradlew.bat --version + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + # Some suites need prerequisite aggregate tasks (for example, smoke tests + # stage the agent distribution before running), so preserve each target as + # a separate Gradle argument. + $gradleArguments = @(($env:GRADLE_TARGET -split '\s+') | Where-Object { $_ }) + $gradleArguments += @( + "-Dscan.capture-resource-usage=false", + # Formatting is validated by the dedicated GitLab Spotless job. + "-x", + "spotlessCheck", + # buildSrc is an included build, so it needs qualified exclusions. + "-x", + ":buildSrc:modifiable-config-agent:spotlessCheck", + "-x", + ":buildSrc:call-site-instrumentation-plugin:spotlessCheck", + "-PskipFlakyTests", + "-PtestJvm=$($env:testJvm)", + "-Pslot=$($env:CI_SPLIT)", + "--build-cache", + "--stacktrace", + "--no-daemon", + "--parallel", + "--max-workers=$($env:GRADLE_WORKERS)", + "--continue" + ) + + & .\gradlew.bat @gradleArguments + exit $LASTEXITCODE +} +finally { + Set-Content -Path $wrapperProperties -Value $originalWrapperProperties -NoNewline +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/HostPlatform.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/HostPlatform.kt index 40837194ecd..2de11c3f967 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/HostPlatform.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/HostPlatform.kt @@ -1,8 +1,17 @@ package datadog.gradle.plugin +import java.io.File import java.util.Locale object HostPlatform { + @JvmStatic + fun isWindows(): Boolean = isExpectedOs("windows") + + /** Returns the complete command needed to run the repository Maven wrapper on this host. */ + @JvmStatic + fun mavenWrapperCommand(directory: File, arguments: List): List = + mavenWrapperCommand(directory, arguments, System.getProperty("os.name", "")) + @JvmStatic fun isLinuxArm64(): Boolean = isExpectedOs("linux") && isArm64() @@ -14,6 +23,17 @@ object HostPlatform { return osName.contains(expectedOs) } + internal fun mavenWrapperCommand( + directory: File, + arguments: List, + osName: String, + ): List { + val windows = osName.lowercase(Locale.ROOT).contains("windows") + val wrapper = File(directory, if (windows) "mvnw.cmd" else "mvnw").absolutePath + val command = if (windows) listOf("cmd", "/c", wrapper) else listOf(wrapper) + return command + arguments + } + private fun isArm64(): Boolean { val osArch = System.getProperty("os.arch", "").lowercase(Locale.ROOT) return osArch.contains("aarch64") || osArch.contains("arm64") diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/HostPlatformTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/HostPlatformTest.kt new file mode 100644 index 00000000000..80c301bb91d --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/HostPlatformTest.kt @@ -0,0 +1,40 @@ +package datadog.gradle.plugin + +import java.io.File +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class HostPlatformTest { + private val repositoryDirectory = File("repository") + private val arguments = listOf("-Dexample=true", "package") + + @Test + fun `Maven wrapper command uses cmd on Windows`() { + assertThat( + HostPlatform.mavenWrapperCommand(repositoryDirectory, arguments, "Windows Server 2025") + ) + .containsExactly( + "cmd", + "/c", + File(repositoryDirectory, "mvnw.cmd").absolutePath, + "-Dexample=true", + "package", + ) + } + + @Test + fun `Maven wrapper command runs wrapper directly on Unix`() { + assertThat(HostPlatform.mavenWrapperCommand(repositoryDirectory, arguments, "Mac OS X")) + .containsExactly( + File(repositoryDirectory, "mvnw").absolutePath, + "-Dexample=true", + "package", + ) + assertThat(HostPlatform.mavenWrapperCommand(repositoryDirectory, arguments, "Linux")) + .containsExactly( + File(repositoryDirectory, "mvnw").absolutePath, + "-Dexample=true", + "package", + ) + } +} diff --git a/components/native-loader/build.gradle.kts b/components/native-loader/build.gradle.kts index 63e17094130..aee60576305 100644 --- a/components/native-loader/build.gradle.kts +++ b/components/native-loader/build.gradle.kts @@ -5,4 +5,6 @@ plugins { dependencies { implementation(project(":components:environment")) + + testImplementation("com.google.jimfs:jimfs:1.1") } diff --git a/components/native-loader/gradle.lockfile b/components/native-loader/gradle.lockfile index 4708a19b318..66b13881d35 100644 --- a/components/native-loader/gradle.lockfile +++ b/components/native-loader/gradle.lockfile @@ -11,6 +11,8 @@ com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.code.findbugs:jsr305:3.0.2=compileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath com.google.code.gson:gson:2.14.0=spotbugs com.google.errorprone:error_prone_annotations:2.48.0=spotbugs +com.google.guava:guava:18.0=testCompileClasspath,testRuntimeClasspath +com.google.jimfs:jimfs:1.1=testCompileClasspath,testRuntimeClasspath com.thoughtworks.qdox:qdox:1.12.1=codenarc commons-io:commons-io:2.21.0=spotbugs de.thetaphi:forbiddenapis:3.10=compileClasspath diff --git a/components/native-loader/src/main/java/datadog/nativeloader/NativeLoader.java b/components/native-loader/src/main/java/datadog/nativeloader/NativeLoader.java index 8a55eabe570..8a41dcaa7d9 100644 --- a/components/native-loader/src/main/java/datadog/nativeloader/NativeLoader.java +++ b/components/native-loader/src/main/java/datadog/nativeloader/NativeLoader.java @@ -4,17 +4,16 @@ import java.io.IOException; import java.io.InputStream; import java.net.URL; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.FileAttribute; -import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.PosixFilePermissions; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.Set; /** * NativeLoader is intended as a more feature rich replacement for calling {@link @@ -379,20 +378,28 @@ private TempFileHelper() {} static Path createTempFile(Path tempDir, String libname, String libExt) throws IOException, SecurityException { - FileAttribute> permAttrs = - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")); + FileAttribute[] fileAttributes = new FileAttribute[0]; + if (supportsPosix(tempDir)) { + fileAttributes = + new FileAttribute[] { + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")) + }; + } if (tempDir == null) { - return Files.createTempFile(libname, "." + libExt, permAttrs); + return Files.createTempFile(libname, "." + libExt, fileAttributes); } else { - Files.createDirectories( - tempDir, - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); - - return Files.createTempFile(tempDir, libname, "." + libExt, permAttrs); + Files.createDirectories(tempDir, fileAttributes); + return Files.createTempFile(tempDir, libname, "." + libExt, fileAttributes); } } + private static boolean supportsPosix(Path tempDir) { + return (tempDir == null ? FileSystems.getDefault() : tempDir.getFileSystem()) + .supportedFileAttributeViews() + .contains("posix"); + } + static boolean delete(File tempFile) { boolean deleted = tempFile.delete(); if (!deleted) tempFile.deleteOnExit(); diff --git a/components/native-loader/src/test/java/datadog/nativeloader/NativeLoaderTest.java b/components/native-loader/src/test/java/datadog/nativeloader/NativeLoaderTest.java index 0a44b91f912..85d54ced7ff 100644 --- a/components/native-loader/src/test/java/datadog/nativeloader/NativeLoaderTest.java +++ b/components/native-loader/src/test/java/datadog/nativeloader/NativeLoaderTest.java @@ -11,12 +11,16 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; import java.io.File; import java.io.IOException; import java.io.UncheckedIOException; import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -29,6 +33,21 @@ import org.junit.jupiter.api.Test; public class NativeLoaderTest { + @Test + public void createTempFileOnNonPosixFileSystem() throws IOException { + try (FileSystem fileSystem = Jimfs.newFileSystem(Configuration.windows())) { + assertFalse(fileSystem.supportedFileAttributeViews().contains("posix")); + Path tempDir = fileSystem.getPath("C:\\temp"); + + Path tempFile = NativeLoader.TempFileHelper.createTempFile(tempDir, "library", "dll"); + + assertTrue(Files.isRegularFile(tempFile)); + assertTrue(tempFile.startsWith(tempDir)); + assertTrue(tempFile.getFileName().toString().startsWith("library")); + assertTrue(tempFile.getFileName().toString().endsWith(".dll")); + } + } + @Test public void preloaded() throws LibraryLoadException { NativeLoader loader = NativeLoader.builder().preloaded("preloaded1", "preloaded2").build(); @@ -458,6 +477,7 @@ public void fromJarBackedClassLoader_with_tempDir() throws IOException, LibraryL @Test public void fromJarBackedClassLoader_with_unwritable_tempDir() throws IOException, LibraryLoadException { + requirePosix(); Path jar = jar("test-data"); try { Path noWriteDir = Paths.get("no-write-temp"); @@ -488,6 +508,7 @@ public void fromJarBackedClassLoader_with_unwritable_tempDir() @Test public void fromJarBackedClassLoader_with_locked_file() throws IOException, LibraryLoadException { + requirePosix(); Path jar = jar("test-data"); try { Path tempDir = Paths.get("temp"); @@ -514,12 +535,22 @@ public void fromJarBackedClassLoader_with_locked_file() throws IOException, Libr void deleteHelper(Path dir) { try { - Files.setPosixFilePermissions(dir, posixPerms("rwx------")); + if (isPosix()) { + Files.setPosixFilePermissions(dir, posixPerms("rwx------")); + } Files.delete(dir); } catch (IOException e) { } } + static void requirePosix() { + assumeTrue(isPosix(), "Skipping POSIX-only test on non-POSIX file system"); + } + + static boolean isPosix() { + return FileSystems.getDefault().supportedFileAttributeViews().contains("posix"); + } + static URLClassLoader createClassLoader(Path... paths) { return new URLClassLoader(urls(paths)); } @@ -549,7 +580,7 @@ static Path jar(Path dir) { } static Path jarHelper(Path dir) throws IOException { - Path jarPath = Files.createTempFile(dir.toFile().getName(), ".jar", posixAttr("rwx------")); + Path jarPath = Files.createTempFile(dir.toFile().getName(), ".jar"); try (JarOutputStream jarStream = new JarOutputStream(Files.newOutputStream(jarPath))) { Files.walk(dir) diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/DatadogClassLoader.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/DatadogClassLoader.java index aaf263901ff..07b48132bff 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/DatadogClassLoader.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/DatadogClassLoader.java @@ -18,7 +18,7 @@ import org.slf4j.LoggerFactory; /** Provides access to Datadog internal classes. */ -public final class DatadogClassLoader extends SecureClassLoader { +public final class DatadogClassLoader extends SecureClassLoader implements AutoCloseable { static { ClassLoader.registerAsParallelCapable(); } @@ -167,6 +167,13 @@ byte[] loadClassBytes(String name) throws ClassNotFoundException { throw new ClassNotFoundException(name); } + @Override + public void close() throws IOException { + if (agentJarFile != null) { + agentJarFile.close(); + } + } + @Override protected Package getPackage(String name) { synchronized (definedPackages) { diff --git a/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/DatadogClassLoaderTest.java b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/DatadogClassLoaderTest.java index 376b035a043..dfa443d5374 100644 --- a/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/DatadogClassLoaderTest.java +++ b/dd-java-agent/agent-bootstrap/src/test/java/datadog/trace/bootstrap/DatadogClassLoaderTest.java @@ -152,20 +152,20 @@ void findResourceUsesAgentJarUrlAsPrefix(@org.junit.jupiter.api.io.TempDir File spacedJar.toPath()); URL spacedJarUrl = spacedJar.toURI().toURL(); - DatadogClassLoader ddLoader = new DatadogClassLoader(spacedJarUrl, null); - - URL resource = ddLoader.findResource("a/A.class"); - assertNotNull(resource, "findResource should locate a/A.class in the test jar"); - - String expectedPrefix = "jar:" + spacedJarUrl + "!/"; - assertTrue( - resource.toString().startsWith(expectedPrefix), - () -> - "resource URL (" - + resource - + ") should start with the agent jar URL prefix (" - + expectedPrefix - + ") — pre-fix code derives the prefix from JarFile.getName()," - + " which leaves the space unencoded and on Windows produces a malformed URL."); + try (DatadogClassLoader ddLoader = new DatadogClassLoader(spacedJarUrl, null)) { + URL resource = ddLoader.findResource("a/A.class"); + assertNotNull(resource, "findResource should locate a/A.class in the test jar"); + + String expectedPrefix = "jar:" + spacedJarUrl + "!/"; + assertTrue( + resource.toString().startsWith(expectedPrefix), + () -> + "resource URL (" + + resource + + ") should start with the agent jar URL prefix (" + + expectedPrefix + + ") — pre-fix code derives the prefix from JarFile.getName()," + + " which leaves the space unencoded and on Windows produces a malformed URL."); + } } } diff --git a/dd-java-agent/agent-ci-visibility/civisibility-test-fixtures/src/main/java/datadog/trace/civisibility/CiVisibilityTestUtils.java b/dd-java-agent/agent-ci-visibility/civisibility-test-fixtures/src/main/java/datadog/trace/civisibility/CiVisibilityTestUtils.java index fb9ff951a88..e3ccf99c382 100644 --- a/dd-java-agent/agent-ci-visibility/civisibility-test-fixtures/src/main/java/datadog/trace/civisibility/CiVisibilityTestUtils.java +++ b/dd-java-agent/agent-ci-visibility/civisibility-test-fixtures/src/main/java/datadog/trace/civisibility/CiVisibilityTestUtils.java @@ -235,7 +235,7 @@ public static Map assertData( // String.valueOf before storing it in the replacement map. for (Map.Entry e : additionalReplacements.entrySet()) { replacementMap.put( - labelGenerator.forKey(e.getKey()), "\"" + String.valueOf(e.getValue()) + "\""); + labelGenerator.forKey(e.getKey()), serializeJsonString(String.valueOf(e.getValue()))); } // ignore provided tags @@ -460,7 +460,7 @@ Map generateReplacementMap( if (value != null) { String stringValue; if (value instanceof String) { - stringValue = "\"" + ((String) value).replace("\"", "\\\"") + "\""; + stringValue = serializeJsonString((String) value); } else { stringValue = String.valueOf(value); } @@ -478,6 +478,10 @@ Map generateReplacementMap( } } + private static String serializeJsonString(String value) { + return JSON_MAPPER.valueToTree(value).toString(); + } + private static final class LabelGenerator { private static final Pattern ERASED_CHARS = Pattern.compile("[\\[\\]']"); private static final Pattern REPLACED_CHARS = Pattern.compile("[.-]"); diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ci/AppVeyorInfo.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ci/AppVeyorInfo.java index f8bc31dd090..8f0021d5e78 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ci/AppVeyorInfo.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/ci/AppVeyorInfo.java @@ -65,7 +65,8 @@ private static String getCommitMessage(String messageSubject, String messageBody if (messageBody == null) { return messageSubject; } - return String.format("%s%n%s", messageSubject, messageBody); + // Git commit messages use LF regardless of the host platform. + return messageSubject + '\n' + messageBody; } @Override diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/git/LocalFSGitInfoExtractor.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/git/LocalFSGitInfoExtractor.java index 55832a06836..4845e3c838c 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/git/LocalFSGitInfoExtractor.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/git/LocalFSGitInfoExtractor.java @@ -411,11 +411,16 @@ private String readFile(final Path filepath) throws IOException { } final String content = new String(Files.readAllBytes(filepath)); - if (content.endsWith("\n")) { - return content.substring(0, content.length() - 1); + // Git metadata can end with either LF or CRLF, regardless of the host platform. + int end = content.length(); + while (end > 0) { + char last = content.charAt(end - 1); + if (last != '\r' && last != '\n') { + break; + } + end--; } - - return content; + return content.substring(0, end); } private static VersionedPackGitInfoExtractor lookupExtractor(final short packVersion) { diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/git/tree/ShellGitClient.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/git/tree/ShellGitClient.java index 8f63a088b28..b65eaf21fb3 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/git/tree/ShellGitClient.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/git/tree/ShellGitClient.java @@ -45,7 +45,7 @@ public class ShellGitClient implements GitClient { Arrays.asList("release/", "hotfix/"); private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); private static final String ORIGIN = "origin"; - private static final Pattern COMMIT_INFO_SPLIT = Pattern.compile("\",\""); + private static final Pattern COMMIT_INFO_SPLIT = Pattern.compile("\\x00"); private final CiVisibilityMetricCollector metricCollector; private final String repoRoot; @@ -227,11 +227,14 @@ public void unshallow(@Nullable String remoteCommitReference) public String getGitFolder() throws IOException, TimeoutException, InterruptedException { return executeCommand( Command.OTHER, - () -> - commandExecutor - .executeCommand( - IOUtils::readFully, buildGitCommand("rev-parse", "--absolute-git-dir")) - .trim()); + () -> { + String path = + commandExecutor + .executeCommand( + IOUtils::readFully, buildGitCommand("rev-parse", "--absolute-git-dir")) + .trim(); + return Paths.get(path).normalize().toString(); + }); } /** @@ -248,10 +251,14 @@ public String getGitFolder() throws IOException, TimeoutException, InterruptedEx public String getRepoRoot() throws IOException, TimeoutException, InterruptedException { return executeCommand( Command.OTHER, - () -> - commandExecutor - .executeCommand(IOUtils::readFully, buildGitCommand("rev-parse", "--show-toplevel")) - .trim()); + () -> { + String path = + commandExecutor + .executeCommand( + IOUtils::readFully, buildGitCommand("rev-parse", "--show-toplevel")) + .trim(); + return Paths.get(path).normalize().toString(); + }); } /** @@ -438,7 +445,7 @@ public CommitInfo getCommitInfo(String commit, boolean fetchIfNotPresent) "show", commit, "-s", - "--format=%H\",\"%an\",\"%ae\",\"%aI\",\"%cn\",\"%ce\",\"%cI\",\"%B")) + "--format=%H%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B")) .trim(); } catch (ShellCommandExecutor.ShellCommandFailedException e) { LOGGER.error("Failed to fetch commit info", e); diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/CompilerAidedSourcePathResolver.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/CompilerAidedSourcePathResolver.java index 35cef73d1d6..2a851e5df91 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/CompilerAidedSourcePathResolver.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/CompilerAidedSourcePathResolver.java @@ -1,7 +1,9 @@ package datadog.trace.civisibility.source; import datadog.compiler.utils.CompilerUtils; -import java.io.File; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Collection; import java.util.Collections; import javax.annotation.Nonnull; @@ -9,19 +11,25 @@ public class CompilerAidedSourcePathResolver implements SourcePathResolver { - private final String repoRoot; + private final Path repoRoot; public CompilerAidedSourcePathResolver(String repoRoot) { - this.repoRoot = repoRoot.endsWith(File.separator) ? repoRoot : repoRoot + File.separator; + this.repoRoot = Paths.get(repoRoot).normalize(); } @Nonnull @Override public Collection getSourcePaths(@Nonnull Class c) { String absoluteSourcePath = CompilerUtils.getSourcePath(c); - if (absoluteSourcePath != null && absoluteSourcePath.startsWith(repoRoot)) { - return Collections.singletonList(absoluteSourcePath.substring(repoRoot.length())); - } else { + if (absoluteSourcePath == null) { + return Collections.emptyList(); + } + try { + Path sourcePath = Paths.get(absoluteSourcePath).normalize(); + return sourcePath.startsWith(repoRoot) + ? Collections.singletonList(repoRoot.relativize(sourcePath).toString()) + : Collections.emptyList(); + } catch (InvalidPathException e) { return Collections.emptyList(); } } diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/Utils.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/Utils.java index a4423f727d5..67cdd0ebad5 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/Utils.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/Utils.java @@ -1,6 +1,5 @@ package datadog.trace.civisibility.source; -import java.io.File; import java.io.IOException; import java.io.InputStream; import javax.annotation.Nonnull; @@ -76,7 +75,7 @@ public static String stripNestedClassNames(@Nonnull String className) { @Nonnull public static String toTrieKey(@Nonnull String relativePath) { - return stripExtension(relativePath).replace(File.separatorChar, '.'); + return stripExtension(relativePath).replace('/', '.').replace('\\', '.'); } @Nonnull diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/index/PackageResolverImpl.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/index/PackageResolverImpl.java index c03203a09d8..d0d047867a3 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/index/PackageResolverImpl.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/index/PackageResolverImpl.java @@ -2,7 +2,6 @@ import datadog.trace.api.civisibility.domain.Language; import java.io.BufferedReader; -import java.io.File; import java.io.IOException; import java.nio.file.FileSystem; import java.nio.file.Files; @@ -70,7 +69,8 @@ public Path getPackage(Path sourceFile) throws IOException { String packageName = line.substring(packageNameStart, packageNameEnd); Path packagePath; try { - packagePath = fileSystem.getPath(packageName.replace('.', File.separatorChar)); + packagePath = + fileSystem.getPath(packageName.replace('.', fileSystem.getSeparator().charAt(0))); } catch (InvalidPathException e) { log.debug("Invalid package {} found for source file {}", packageName, sourceFile, e); continue; diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/index/RepoIndex.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/index/RepoIndex.java index 76a758d3af6..f8dd1d032ea 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/index/RepoIndex.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/index/RepoIndex.java @@ -160,28 +160,34 @@ static final class SourceRoot { final Language language; + final char separator; + SourceRoot(String relativePath, Language language) { + this(relativePath, language, File.separatorChar); + } + + SourceRoot(String relativePath, Language language, char separator) { this.relativePath = relativePath; this.language = language; + this.separator = separator; } /** Resolves a trie key (dot-separated) to a full source path relative to the source root. */ String resolveSourcePath(String trieKey) { - return relativePath - + File.separatorChar - + trieKey.replace('.', File.separatorChar) - + language.getExtension(); + return relativePath + separator + trieKey.replace('.', separator) + language.getExtension(); } static void serialize(Serializer s, SourceRoot sourceRoot) { s.write(sourceRoot.relativePath); s.write(sourceRoot.language.ordinal()); + s.write(sourceRoot.separator); } static SourceRoot deserialize(ByteBuffer buffer) { String relativePath = Serializer.readString(buffer); Language language = Language.getByOrdinal(Serializer.readInt(buffer)); - return new SourceRoot(relativePath, language); + char separator = (char) Serializer.readInt(buffer); + return new SourceRoot(relativePath, language, separator); } @Override @@ -193,12 +199,14 @@ public boolean equals(Object o) { return false; } SourceRoot that = (SourceRoot) o; - return Objects.equals(relativePath, that.relativePath) && language == that.language; + return Objects.equals(relativePath, that.relativePath) + && language == that.language + && separator == that.separator; } @Override public int hashCode() { - return Objects.hash(relativePath, language); + return Objects.hash(relativePath, language, separator); } } } diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/index/RepoIndexBuilder.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/index/RepoIndexBuilder.java index 2c223c34f90..38d220ab3e5 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/index/RepoIndexBuilder.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/source/index/RepoIndexBuilder.java @@ -171,7 +171,10 @@ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { String relativeSourceRoot = repoRoot.relativize(sourceRoot).toString(); int sourceRootIdx = sourceRoots.computeIfAbsent( - new RepoIndex.SourceRoot(relativeSourceRoot, language), + new RepoIndex.SourceRoot( + relativeSourceRoot, + language, + repoRoot.getFileSystem().getSeparator().charAt(0)), sr -> sourceRootCounter.getAndIncrement()); String relativePath = sourceRoot.relativize(file).toString(); diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/utils/FileUtils.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/utils/FileUtils.java index eb668ac8b4b..d32f9222b49 100644 --- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/utils/FileUtils.java +++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/utils/FileUtils.java @@ -3,12 +3,14 @@ import datadog.environment.SystemProperties; import de.thetaphi.forbiddenapis.SuppressForbidden; import java.io.IOException; +import java.nio.file.AccessDeniedException; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.DosFileAttributeView; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,7 +28,7 @@ public static void delete(Path directory) throws IOException { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { - Files.delete(file); + deleteFile(file); return FileVisitResult.CONTINUE; } @@ -38,6 +40,20 @@ public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOEx }); } + private static void deleteFile(Path file) throws IOException { + try { + Files.delete(file); + } catch (AccessDeniedException e) { + DosFileAttributeView dosAttributes = + Files.getFileAttributeView(file, DosFileAttributeView.class); + if (dosAttributes == null || !dosAttributes.readAttributes().isReadOnly()) { + throw e; + } + dosAttributes.setReadOnly(false); + Files.delete(file); + } + } + /** * Search the parent path that contains the target file. If the current path does not have the * target file, the method continues with the parent path. If the path is not found, it returns diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/CompilerAidedSourcePathResolverTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/CompilerAidedSourcePathResolverTest.groovy index c1d516b2256..5dafed0d278 100644 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/CompilerAidedSourcePathResolverTest.groovy +++ b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/CompilerAidedSourcePathResolverTest.groovy @@ -3,6 +3,8 @@ package datadog.trace.civisibility.source import datadog.compiler.annotations.SourcePath import spock.lang.Specification +import static datadog.trace.test.util.PlatformTestUtils.normalizePathSeparators + class CompilerAidedSourcePathResolverTest extends Specification { public static final String REPO_ROOT = "/repo/root" @@ -15,10 +17,11 @@ class CompilerAidedSourcePathResolverTest extends Specification { when: def path = sourcePathResolver.getSourcePaths(clazz) + def normalizedPaths = normalizePathSeparators(path) then: - path.size() == expectedPath.size() - path.containsAll(expectedPath) + normalizedPaths.size() == expectedPath.size() + normalizedPaths.containsAll(expectedPath) where: clazz | expectedPath diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/UtilsTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/UtilsTest.groovy index ab5984617db..8bcb9dd4135 100644 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/UtilsTest.groovy +++ b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/UtilsTest.groovy @@ -19,6 +19,14 @@ class UtilsTest extends Specification { clazz << [UtilsTestClass, Object] } + def "test converts #path to trie key"() { + expect: + Utils.toTrieKey(path) == 'foo.bar.Baz' + + where: + path << ['foo/bar/Baz.java', 'foo\\bar\\Baz.java'] + } + private byte[] readMagicNumber(InputStream stream) { def bytes = new byte[4] def totalRead = 0 diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/index/RepoIndexSourcePathResolverTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/index/RepoIndexSourcePathResolverTest.groovy index df61ba11170..0f5216f5c8d 100644 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/index/RepoIndexSourcePathResolverTest.groovy +++ b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/index/RepoIndexSourcePathResolverTest.groovy @@ -134,7 +134,7 @@ class RepoIndexSourcePathResolverTest extends Specification { def "test file-indexing failure"() { setup: - def classPath = fileSystem.getPath(generateSourceFileName(RepoIndexSourcePathResolverTest, repoRoot)) + def classPath = generateSourceFilePath(RepoIndexSourcePathResolverTest, repoRoot) packageResolver.getPackage(classPath) >> { throw new IOException() } Files.createDirectories(classPath.getParent()) @@ -185,7 +185,7 @@ class RepoIndexSourcePathResolverTest extends Specification { } private String givenSourceFile(Class c, String sourceRoot, Language language = Language.GROOVY) { - def classPath = fileSystem.getPath(generateSourceFileName(c, sourceRoot, language)) + def classPath = generateSourceFilePath(c, sourceRoot, language) packageResolver.getPackage(classPath) >> fileSystem.getPath(sourceRoot).relativize(classPath).getParent() givenRepoFile(classPath) @@ -198,8 +198,9 @@ class RepoIndexSourcePathResolverTest extends Specification { Files.write(file, "STUB FILE BODY".getBytes()) } - private static String generateSourceFileName(Class c, String sourceRoot, Language language = Language.GROOVY) { - return sourceRoot + File.separator + c.getName().replace(".", File.separator) + language.extension + private Path generateSourceFilePath(Class c, String sourceRoot, Language language = Language.GROOVY) { + def relativePath = c.getName().replace('.' as char, fileSystem.separator.charAt(0)) + language.extension + return fileSystem.getPath(sourceRoot).resolve(relativePath) } private static getRepoRoot() { diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/index/RepoIndexTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/index/RepoIndexTest.groovy index 70da9c5780a..231eddd5d1e 100644 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/index/RepoIndexTest.groovy +++ b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/source/index/RepoIndexTest.groovy @@ -2,8 +2,11 @@ package datadog.trace.civisibility.source.index import datadog.instrument.utils.ClassNameTrie import datadog.trace.api.civisibility.domain.Language +import java.nio.file.Paths import spock.lang.Specification +import static datadog.trace.test.util.PlatformTestUtils.normalizePathSeparators + class RepoIndexTest extends Specification { def "test serialization and deserialization"() { @@ -28,9 +31,11 @@ class RepoIndexTest extends Specification { then: deserialized.getSourcePaths(RepoIndexTest).size() == 1 - deserialized.getSourcePaths(RepoIndexTest) .contains("myClassSourceRoot/" + myClassName.replace('.' as char, File.separatorChar) + Language.GROOVY.extension) + normalizePathSeparators(deserialized.getSourcePaths(RepoIndexTest)).contains( + "myClassSourceRoot/" + myClassName.replace('.' as char, '/' as char) + Language.GROOVY.extension) deserialized.getSourcePaths(RepoIndexSourcePathResolverTest).size() == 1 - deserialized.getSourcePaths(RepoIndexSourcePathResolverTest).contains("myOtherClassSourceRoot/" + myOtherClassName.replace('.' as char, File.separatorChar) + Language.GROOVY.extension) + normalizePathSeparators(deserialized.getSourcePaths(RepoIndexSourcePathResolverTest)).contains( + "myOtherClassSourceRoot/" + myOtherClassName.replace('.' as char, '/' as char) + Language.GROOVY.extension) } def "test serialization and deserialization with duplicate keys"() { @@ -46,8 +51,8 @@ class RepoIndexTest extends Specification { new RepoIndex.SourceRoot("sourceRoot2", Language.GROOVY)) def duplicateKeys = [(myClassName): [ - "sourceRoot1/" + myClassName.replace('.' as char, File.separatorChar) + Language.GROOVY.extension, - "sourceRoot2/" + myClassName.replace('.' as char, File.separatorChar) + Language.GROOVY.extension + "sourceRoot1/" + myClassName.replace('.' as char, '/' as char) + Language.GROOVY.extension, + "sourceRoot2/" + myClassName.replace('.' as char, '/' as char) + Language.GROOVY.extension ]] def repoIndex = new RepoIndex(trie, duplicateKeys, sourceRoots, Collections.emptyList()) @@ -59,9 +64,9 @@ class RepoIndexTest extends Specification { then: def paths = deserialized.getSourcePaths(RepoIndexTest) paths.size() == 2 - paths.containsAll([ - "sourceRoot1/" + myClassName.replace('.' as char, File.separatorChar) + Language.GROOVY.extension, - "sourceRoot2/" + myClassName.replace('.' as char, File.separatorChar) + Language.GROOVY.extension + normalizePathSeparators(paths).containsAll([ + "sourceRoot1/" + myClassName.replace('.' as char, '/' as char) + Language.GROOVY.extension, + "sourceRoot2/" + myClassName.replace('.' as char, '/' as char) + Language.GROOVY.extension ]) } @@ -77,8 +82,8 @@ class RepoIndexTest extends Specification { new RepoIndex.SourceRoot("debug", Language.GROOVY), new RepoIndex.SourceRoot("release", Language.GROOVY)) - def expectedPath1 = "debug/" + myClassName.replace('.' as char, File.separatorChar) + Language.GROOVY.extension - def expectedPath2 = "release/" + myClassName.replace('.' as char, File.separatorChar) + Language.GROOVY.extension + def expectedPath1 = "debug/" + myClassName.replace('.' as char, '/' as char) + Language.GROOVY.extension + def expectedPath2 = "release/" + myClassName.replace('.' as char, '/' as char) + Language.GROOVY.extension def duplicateKeys = [(myClassName): [expectedPath1, expectedPath2]] def repoIndex = new RepoIndex(trie, duplicateKeys, sourceRoots, Collections.emptyList()) @@ -88,7 +93,7 @@ class RepoIndexTest extends Specification { then: paths.size() == 2 - paths.containsAll([expectedPath1, expectedPath2]) + normalizePathSeparators(paths).containsAll([expectedPath1, expectedPath2]) } def "test getSourcePaths returns single path for non-duplicate key"() { @@ -100,7 +105,7 @@ class RepoIndexTest extends Specification { def trie = trieBuilder.buildTrie() def sourceRoots = Arrays.asList( - new RepoIndex.SourceRoot("src/main/groovy", Language.GROOVY)) + new RepoIndex.SourceRoot(Paths.get("src", "main", "groovy").toString(), Language.GROOVY)) def repoIndex = new RepoIndex(trie, Collections.emptyMap(), sourceRoots, Collections.emptyList()) @@ -109,6 +114,15 @@ class RepoIndexTest extends Specification { then: paths.size() == 1 - paths.first() == "src/main/groovy/" + myClassName.replace('.' as char, File.separatorChar) + Language.GROOVY.extension + normalizePathSeparators(paths.first()) == "src/main/groovy/" + + myClassName.replace('.' as char, '/' as char) + Language.GROOVY.extension + } + + def "test source root uses its filesystem separator"() { + given: + def sourceRoot = new RepoIndex.SourceRoot("src\\main\\groovy", Language.GROOVY, '\\' as char) + + expect: + sourceRoot.resolveSourcePath("example.MyClass") == "src\\main\\groovy\\example\\MyClass.groovy" } } diff --git a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/utils/ShellCommandExecutorTest.groovy b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/utils/ShellCommandExecutorTest.groovy index 5b9dd7527ba..5e3323f5365 100644 --- a/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/utils/ShellCommandExecutorTest.groovy +++ b/dd-java-agent/agent-ci-visibility/src/test/groovy/datadog/trace/civisibility/utils/ShellCommandExecutorTest.groovy @@ -1,6 +1,7 @@ package datadog.trace.civisibility.utils import datadog.communication.util.IOUtils +import datadog.trace.test.util.PortableCommand import spock.lang.Specification import spock.lang.TempDir @@ -18,7 +19,7 @@ class ShellCommandExecutorTest extends Specification { def shellCommandExecutor = new ShellCommandExecutor(temporaryFolder, SHELL_COMMAND_TIMEOUT) when: - def output = shellCommandExecutor.executeCommand(IOUtils::readFully, "echo", "this is a test") + def output = shellCommandExecutor.executeCommand(IOUtils::readFully, *PortableCommand.echo("this is a test")) then: output.trim() == "this is a test" @@ -29,7 +30,7 @@ class ShellCommandExecutorTest extends Specification { def shellCommandExecutor = new ShellCommandExecutor(temporaryFolder, SHELL_COMMAND_TIMEOUT) when: - def output = shellCommandExecutor.executeCommand(IOUtils::readFully, "this is a test".bytes, "cat") + def output = shellCommandExecutor.executeCommand(IOUtils::readFully, "this is a test".bytes, *PortableCommand.cat()) then: output.trim() == "this is a test" @@ -40,7 +41,7 @@ class ShellCommandExecutorTest extends Specification { def shellCommandExecutor = new ShellCommandExecutor(temporaryFolder, 1_000) when: - shellCommandExecutor.executeCommand(IOUtils::readFully, "sleep", "2") + shellCommandExecutor.executeCommand(IOUtils::readFully, *PortableCommand.sleep(2)) then: thrown TimeoutException diff --git a/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/CrashUploader.java b/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/CrashUploader.java index 87fc4592751..04803741763 100644 --- a/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/CrashUploader.java +++ b/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/CrashUploader.java @@ -358,7 +358,7 @@ static String extractErrorMessage(String fileContent) { log.error("No match found for error.message"); return null; } - return Arrays.stream(matcher.group().split(System.lineSeparator())) + return Arrays.stream(matcher.group().split("\\r?\\n")) .filter( s -> !s.equals("# A fatal error has been detected by the Java Runtime Environment:") diff --git a/dd-java-agent/agent-crashtracking/src/test/java/datadog/crashtracking/ScriptInitializerTest.java b/dd-java-agent/agent-crashtracking/src/test/java/datadog/crashtracking/ScriptInitializerTest.java index 53cf66f72a1..e069830b766 100644 --- a/dd-java-agent/agent-crashtracking/src/test/java/datadog/crashtracking/ScriptInitializerTest.java +++ b/dd-java-agent/agent-crashtracking/src/test/java/datadog/crashtracking/ScriptInitializerTest.java @@ -3,9 +3,11 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.io.File; import java.io.IOException; +import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermissions; @@ -148,6 +150,7 @@ void testCrashUploaderNoErrFileSpec() throws IOException { @Test void testCrashUploaderInvalidFolder() throws IOException { + requirePosix(); Files.setPosixFilePermissions(tempDir, PosixFilePermissions.fromString("r-x------")); Path file = tempDir.resolve("dd_crash_uploader.sh"); assertDoesNotThrow( @@ -157,9 +160,16 @@ void testCrashUploaderInvalidFolder() throws IOException { @Test void testOomeInitializeInvalidFolder() throws IOException { + requirePosix(); Files.setPosixFilePermissions(tempDir, PosixFilePermissions.fromString("r-x------")); Path file = tempDir.resolve("dd_oome_notifier.sh"); assertDoesNotThrow(() -> OOMENotifierScriptInitializer.initialize(file + " %p")); assertFalse(Files.exists(file), "File " + file + " should not have been created"); } + + private static void requirePosix() { + assumeTrue( + FileSystems.getDefault().supportedFileAttributeViews().contains("posix"), + "Skipping POSIX-only test on non-POSIX file system"); + } } diff --git a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/propagation/StringModuleTest.groovy b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/propagation/StringModuleTest.groovy index 77e406ffa15..ec4dfc5c6bf 100644 --- a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/propagation/StringModuleTest.groovy +++ b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/propagation/StringModuleTest.groovy @@ -22,6 +22,7 @@ import static com.datadog.iast.taint.TaintUtils.getStringFromTaintFormat import static com.datadog.iast.taint.TaintUtils.taint import static com.datadog.iast.taint.TaintUtils.taintFormat import static com.datadog.iast.taint.TaintUtils.taintObject +import static datadog.trace.test.util.PlatformTestUtils.normalizeLineEndings @CompileDynamic class StringModuleTest extends IastModuleImplTestBase { @@ -1018,14 +1019,14 @@ class StringModuleTest extends IastModuleImplTestBase { } final formatted = String.format(format, args as Object[]) final expected = getStringFromTaintFormat(expectedTainted) - assert expected == formatted // validate expectation is OK + assert expected == normalizeLineEndings(formatted) // validate expectation is OK when: module.onStringFormat(format, args as Object[], formatted) then: final tainted = to.get(formatted) - final formattedResult = taintFormat(formatted, tainted?.ranges) + final formattedResult = normalizeLineEndings(taintFormat(formatted, tainted?.ranges)) assert formattedResult == expectedTainted: tainted?.ranges where: diff --git a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/ApplicationModuleTest.groovy b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/ApplicationModuleTest.groovy index d7345eeddd2..ba3b1e6c3d1 100644 --- a/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/ApplicationModuleTest.groovy +++ b/dd-java-agent/agent-iast/src/test/groovy/com/datadog/iast/sink/ApplicationModuleTest.groovy @@ -17,6 +17,7 @@ import static com.datadog.iast.model.VulnerabilityType.INSECURE_JSP_LAYOUT import static com.datadog.iast.model.VulnerabilityType.SESSION_TIMEOUT import static com.datadog.iast.model.VulnerabilityType.VERB_TAMPERING import static com.datadog.iast.sink.ApplicationModuleImpl.SESSION_REWRITING_EVIDENCE_VALUE +import static datadog.trace.test.util.PlatformTestUtils.normalizePathSeparators class ApplicationModuleTest extends IastModuleImplTestBase { @@ -46,7 +47,7 @@ class ApplicationModuleTest extends IastModuleImplTestBase { void 'check vulnerabilities #path'() { given: final file = ClassLoader.getSystemResource(path) - final realPath = file.path + final realPath = Paths.get(file.toURI()).toString() when: module.onRealPath(realPath) @@ -128,7 +129,7 @@ class ApplicationModuleTest extends IastModuleImplTestBase { if (expectedEvidence instanceof Collection) { if (expectedVulnType == INSECURE_JSP_LAYOUT) { // some of the nested paths can be dropped by the file visitor - final parts = (evidence.value as String).split('\n')*.trim() + final parts = normalizePathSeparators(evidence.value as String).split('\n')*.trim() assert expectedEvidence.any { parts.contains(it) } } else { expectedEvidence.each { diff --git a/dd-java-agent/agent-jmxfetch/src/main/java/datadog/trace/agent/jmxfetch/JMXFetch.java b/dd-java-agent/agent-jmxfetch/src/main/java/datadog/trace/agent/jmxfetch/JMXFetch.java index a46b1cb81a1..25e400c1c86 100644 --- a/dd-java-agent/agent-jmxfetch/src/main/java/datadog/trace/agent/jmxfetch/JMXFetch.java +++ b/dd-java-agent/agent-jmxfetch/src/main/java/datadog/trace/agent/jmxfetch/JMXFetch.java @@ -195,7 +195,7 @@ private static List getInternalMetricFiles() { } log.debug("reading found metricconfigs"); Scanner scanner = new Scanner(metricConfigsStream); - scanner.useDelimiter("\n"); + scanner.useDelimiter("\\r?\\n"); final List result = new ArrayList<>(); final SortedSet integrationName = new TreeSet<>(); while (scanner.hasNext()) { diff --git a/dd-java-agent/agent-logging/build.gradle b/dd-java-agent/agent-logging/build.gradle index 8e8b9d97fc8..bc8d9f1dd6d 100644 --- a/dd-java-agent/agent-logging/build.gradle +++ b/dd-java-agent/agent-logging/build.gradle @@ -26,4 +26,5 @@ dependencies { api libs.slf4j api project(':internal-api') implementation project(':components:json') + testImplementation project(':utils:test-utils') } diff --git a/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/LogValidatingSpecification.groovy b/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/LogValidatingSpecification.groovy index 4da7ed78973..647e254f08d 100644 --- a/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/LogValidatingSpecification.groovy +++ b/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/LogValidatingSpecification.groovy @@ -3,6 +3,8 @@ package datadog.trace.logging import org.slf4j.Marker import spock.lang.Specification +import static datadog.trace.test.util.PlatformTestUtils.normalizeLineEndings + abstract class LogValidatingSpecification extends Specification { LogValidator createValidator(String loggerName) { new LogValidator(loggerName) @@ -13,7 +15,7 @@ abstract class LogValidatingSpecification extends Specification { } private static validateLogLine(LogValidator validator, boolean enabled, String level, String marker, String msg, String emsg) { - def current = validator.outputStream.toString() + def current = normalizeLineEndings(validator.outputStream.toString()) def expected = "" if (enabled) { expected = marker == null ? "$level ${validator.name} - $msg\n" : "$marker ${validator.name} - $msg\n" diff --git a/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/ddlogger/DDLoggerTest.groovy b/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/ddlogger/DDLoggerTest.groovy index d8d299439e4..80495ec20f4 100644 --- a/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/ddlogger/DDLoggerTest.groovy +++ b/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/ddlogger/DDLoggerTest.groovy @@ -10,6 +10,7 @@ import org.slf4j.Logger import static datadog.trace.logging.simplelogger.SLCompatSettings.Names import static datadog.trace.logging.simplelogger.SLCompatSettings.Keys import static datadog.trace.logging.simplelogger.SLCompatSettings.Defaults +import static datadog.trace.test.util.PlatformTestUtils.normalizeLineEndings class DDLoggerTest extends LogValidatingSpecification { @@ -67,7 +68,7 @@ class DDLoggerTest extends LogValidatingSpecification { } static String validateLogLine(OutputStream outputStream, String previous, boolean enabled, String level, String msg, String emsg) { - def total = outputStream.toString() + def total = normalizeLineEndings(outputStream.toString()) def current = total.substring(previous.length()) def expected = "" if (enabled) { @@ -248,7 +249,7 @@ class DDLoggerTest extends LogValidatingSpecification { } then: - outputStream.toString() ==~ /^.* $level foo - log \[exception:java\.io\.IOException: wrong\. at .*\]\n$/ + normalizeLineEndings(outputStream.toString()) ==~ /^.* $level foo - log \[exception:java\.io\.IOException: wrong\. at .*\]\n$/ where: level << LogLevel.values().toList().take(5) // remove LogLevel.OFF @@ -273,7 +274,7 @@ class DDLoggerTest extends LogValidatingSpecification { } then: - outputStream.toString() ==~ /^.* $level foo - log some more \[exception:java\.io\.IOException: wrong\. at .*\]\n$/ + normalizeLineEndings(outputStream.toString()) ==~ /^.* $level foo - log some more \[exception:java\.io\.IOException: wrong\. at .*\]\n$/ where: level << LogLevel.values().toList().take(5) // remove LogLevel.OFF diff --git a/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/simplelogger/SLCompatHelperTest.groovy b/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/simplelogger/SLCompatHelperTest.groovy index 31084ee6494..d247d4671aa 100644 --- a/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/simplelogger/SLCompatHelperTest.groovy +++ b/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/simplelogger/SLCompatHelperTest.groovy @@ -6,8 +6,14 @@ import spock.lang.Specification import java.text.SimpleDateFormat +import static datadog.trace.test.util.PlatformTestUtils.normalizeLineEndings + class SLCompatHelperTest extends Specification { + private static String normalizedOutput(ByteArrayOutputStream outputStream) { + normalizeLineEndings(outputStream.toString()) + } + private class NoStackException extends Exception { NoStackException(String message) { super(message, null, false, false) @@ -73,7 +79,7 @@ class SLCompatHelperTest extends Specification { helper.log(level, null, msg, null) then: - outputStream.toString() == expected + normalizedOutput(outputStream) == expected where: name | level | msg | expected @@ -94,7 +100,7 @@ class SLCompatHelperTest extends Specification { helper.log(LogLevel.ERROR, null, "log", exception) expect: - outputStream.toString() == "[$thread] ERROR foo - log\n${NoStackException.getName()}: wrong\n" + normalizedOutput(outputStream) == "[$thread] ERROR foo - log\n${NoStackException.getName()}: wrong\n" } def "test logging with an embedded exception in the message"() { @@ -112,7 +118,7 @@ class SLCompatHelperTest extends Specification { } expect: - outputStream.toString() ==~ /^.* $level foo - log \[exception:java\.io\.IOException: wrong\. at .*\]\n$/ + normalizedOutput(outputStream) ==~ /^.* $level foo - log \[exception:java\.io\.IOException: wrong\. at .*\]\n$/ where: level << LogLevel.values().toList().take(5) // remove LogLevel.OFF @@ -130,7 +136,7 @@ class SLCompatHelperTest extends Specification { helper.log(LogLevel.ERROR, null, "log", null) expect: - outputStream.toString() ==~ /^\d+ ERROR foo - log\n$/ + normalizedOutput(outputStream) ==~ /^\d+ ERROR foo - log\n$/ } def "test log output with configuration"() { @@ -145,7 +151,7 @@ class SLCompatHelperTest extends Specification { helper.log(level, null, 0, 4711, "thread", "log", null) then: - outputStream.toString() == expected + normalizedOutput(outputStream) == expected where: level | warnS | showB | showS | showL | showT | dateTFS | showDT | jsonE | expected @@ -177,7 +183,7 @@ class SLCompatHelperTest extends Specification { helper.log(level, null, "log", null) then: - outputStream.toString() == expected + normalizedOutput(outputStream) == expected where: level | warnS | showB | showS | showL | showT | dateTFS | showDT | jsonE | expected @@ -197,7 +203,7 @@ class SLCompatHelperTest extends Specification { helper.logJson(level,null,0,4711,"thread","log", null) then: - outputStream.toString() == expected + normalizedOutput(outputStream) == expected where: level | warnS | showB | showS | showL | showT | dateTFS | showDT | jsonE | expected @@ -227,6 +233,6 @@ class SLCompatHelperTest extends Specification { helper.log(LogLevel.INFO, null, "log", exception) } expect: - outputStream.toString() ==~ /^\{"origin":"dd.trace","level":"INFO","logger.name":"foo","message":"log","exception":\{"message":"wrong","stackTrace":\[.*\]\}\}\n$/ + normalizedOutput(outputStream) ==~ /^\{"origin":"dd.trace","level":"INFO","logger.name":"foo","message":"log","exception":\{"message":"wrong","stackTrace":\[.*\]\}\}\n$/ } } diff --git a/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/simplelogger/SLCompatSettingsTest.groovy b/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/simplelogger/SLCompatSettingsTest.groovy index 80669b06463..c0613965aa1 100644 --- a/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/simplelogger/SLCompatSettingsTest.groovy +++ b/dd-java-agent/agent-logging/src/test/groovy/datadog/trace/logging/simplelogger/SLCompatSettingsTest.groovy @@ -138,9 +138,8 @@ class SLCompatSettingsTest extends Specification { def "test log file creation stderr fallback"() { setup: - def dir = File.createTempDir() - dir.setWritable(false, true) - def file = new File(dir, "log") + def parent = File.createTempFile("not-a-directory", ".tmp") + def file = new File(parent, "log") def props = new Properties() props.setProperty(SLCompatSettings.Keys.LOG_FILE, file.getAbsolutePath()) def settings = new SLCompatSettings(props) @@ -150,8 +149,7 @@ class SLCompatSettingsTest extends Specification { ((PrintStreamWrapper) settings.printStream).getOriginalPrintStream() == System.err cleanup: - dir.setWritable(true, true) - dir.delete() + parent.delete() } def "test logNameForName"() { diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/AppSecSystemSpecification.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/AppSecSystemSpecification.groovy index 162008aa865..6937859b9aa 100644 --- a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/AppSecSystemSpecification.groovy +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/AppSecSystemSpecification.groovy @@ -29,6 +29,7 @@ import java.nio.file.Path import java.util.function.BiFunction import static datadog.trace.api.gateway.Events.EVENTS +import static datadog.trace.test.util.PlatformTestUtils.normalizePathSeparators class AppSecSystemSpecification extends DDSpecification { SubscriptionService subService = Mock() @@ -55,7 +56,7 @@ class AppSecSystemSpecification extends DDSpecification { then: def exception = thrown(AbortStartupException) - exception.cause.toString().contains('/file/that/does/not/exist') + normalizePathSeparators(exception.cause.toString()).contains('/file/that/does/not/exist') } void 'system should throw AbortStartupException when config file is not valid JSON'() { diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecConfigServiceImplSpecification.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecConfigServiceImplSpecification.groovy index cc650c3c7e6..a9a01e4ec97 100644 --- a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecConfigServiceImplSpecification.groovy +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/config/AppSecConfigServiceImplSpecification.groovy @@ -122,7 +122,7 @@ class AppSecConfigServiceImplSpecification extends DDSpecification { void 'no subscription to ASM ASM_DD ASM_DATA if custom rules are provided'() { setup: - Path p = Paths.get(getClass().classLoader.getResource('test_multi_config_no_action.json').getPath()) + Path p = Paths.get(getClass().classLoader.getResource('test_multi_config_no_action.json').toURI()) AppSecSystem.active = false when: @@ -142,7 +142,7 @@ class AppSecConfigServiceImplSpecification extends DDSpecification { void 'can load from a different location'() { setup: - Path p = Paths.get(getClass().classLoader.getResource('test_multi_config_no_action.json').getPath()) + Path p = Paths.get(getClass().classLoader.getResource('test_multi_config_no_action.json').toURI()) String capturedPath = null when: diff --git a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/ddwaf/WAFModuleSpecification.groovy b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/ddwaf/WAFModuleSpecification.groovy index 1c3bb0b32ff..742be67d9fa 100644 --- a/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/ddwaf/WAFModuleSpecification.groovy +++ b/dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/ddwaf/WAFModuleSpecification.groovy @@ -30,6 +30,7 @@ import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import com.squareup.moshi.Types import datadog.appsec.api.blocking.BlockingContentType +import datadog.environment.OperatingSystem import datadog.metrics.api.Monitoring import datadog.remoteconfig.ConfigurationPoller import datadog.remoteconfig.Product @@ -48,6 +49,7 @@ import datadog.trace.bootstrap.instrumentation.api.AgentTracer import datadog.trace.test.util.DDSpecification import datadog.trace.util.stacktrace.StackTraceEvent import okio.Okio +import spock.lang.IgnoreIf import spock.lang.Shared import spock.lang.Unroll @@ -1192,6 +1194,9 @@ class WAFModuleSpecification extends DDSpecification { 0 * _ } + @IgnoreIf(reason = "libddwaf 2.0.1 does not preserve config override precedence on Windows", value = { + OperatingSystem.isWindows() + }) void 'rule toggling data given through configuration'() { ChangeableFlow flow = Mock() initialRuleAdd() diff --git a/dd-java-agent/cws-tls/src/test/groovy/datadog/cws/tls/ErpcTlsTest.groovy b/dd-java-agent/cws-tls/src/test/groovy/datadog/cws/tls/ErpcTlsTest.groovy index fe7306165d3..8d0b4c29db8 100644 --- a/dd-java-agent/cws-tls/src/test/groovy/datadog/cws/tls/ErpcTlsTest.groovy +++ b/dd-java-agent/cws-tls/src/test/groovy/datadog/cws/tls/ErpcTlsTest.groovy @@ -4,7 +4,11 @@ import com.sun.jna.Native import datadog.trace.api.DD128bTraceId import datadog.trace.test.util.DDSpecification +import spock.lang.IgnoreIf +@IgnoreIf(reason = "CWS eRPC TLS is only supported on Linux", value = { + !System.getProperty("os.name").equalsIgnoreCase("Linux") +}) class ErpcTlsTest extends DDSpecification { def "register trace and span to tls"() { setup: diff --git a/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy b/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy index 46abd76a6da..e5acfd96742 100644 --- a/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy +++ b/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/base/HttpServerTest.groovy @@ -221,6 +221,14 @@ abstract class HttpServerTest extends WithHttpServer { return uri } + String normalizeServerHostname(String value) { + value + } + + String normalizeServerUrl(String value) { + value + } + Serializable expectedServerSpanRoute(ServerEndpoint endpoint) { null } @@ -2650,8 +2658,12 @@ abstract class HttpServerTest extends WithHttpServer { "$Tags.NETWORK_CLIENT_IP" null "$Tags.HTTP_CLIENT_IP" clientIp } - "$Tags.HTTP_HOSTNAME" address.host - "$Tags.HTTP_URL" "$expectedUrl" + "$Tags.HTTP_HOSTNAME" { + normalizeServerHostname(it as String) == normalizeServerHostname(address.host) + } + "$Tags.HTTP_URL" { + normalizeServerUrl(it as String) == normalizeServerUrl("$expectedUrl") + } "$Tags.HTTP_METHOD" method "$Tags.HTTP_STATUS" expectedStatus "$Tags.HTTP_USER_AGENT" String diff --git a/dd-java-agent/instrumentation/aerospike-4.0/src/test/groovy/datadog/trace/instrumentation/aerospike4/AerospikeBaseTest.groovy b/dd-java-agent/instrumentation/aerospike-4.0/src/test/groovy/datadog/trace/instrumentation/aerospike4/AerospikeBaseTest.groovy index 746f57577c8..7cd917683c7 100644 --- a/dd-java-agent/instrumentation/aerospike-4.0/src/test/groovy/datadog/trace/instrumentation/aerospike4/AerospikeBaseTest.groovy +++ b/dd-java-agent/instrumentation/aerospike-4.0/src/test/groovy/datadog/trace/instrumentation/aerospike4/AerospikeBaseTest.groovy @@ -5,14 +5,19 @@ import static java.util.concurrent.TimeUnit.SECONDS import static org.testcontainers.containers.wait.strategy.Wait.forLogMessage import com.github.dockerjava.api.model.Ulimit +import datadog.environment.OperatingSystem import datadog.trace.agent.test.asserts.TraceAssert import datadog.trace.agent.test.naming.VersionedNamingTestBase import datadog.trace.api.DDSpanTypes import datadog.trace.bootstrap.instrumentation.api.Tags import datadog.trace.core.DDSpan import org.testcontainers.containers.GenericContainer +import spock.lang.IgnoreIf import spock.lang.Shared +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class AerospikeBaseTest extends VersionedNamingTestBase { @Shared diff --git a/dd-java-agent/instrumentation/aws-java/aws-java-dynamodb-2.0/src/test/groovy/DynamoDbClientTest.groovy b/dd-java-agent/instrumentation/aws-java/aws-java-dynamodb-2.0/src/test/groovy/DynamoDbClientTest.groovy index 02acd67be37..b601d3cba9e 100644 --- a/dd-java-agent/instrumentation/aws-java/aws-java-dynamodb-2.0/src/test/groovy/DynamoDbClientTest.groovy +++ b/dd-java-agent/instrumentation/aws-java/aws-java-dynamodb-2.0/src/test/groovy/DynamoDbClientTest.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import datadog.trace.agent.test.InstrumentationSpecification import datadog.trace.api.DDSpanTypes import datadog.trace.api.DDTraceId @@ -28,6 +31,9 @@ import spock.lang.Shared import java.time.Duration +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) class DynamoDbClientTest extends InstrumentationSpecification { static final LOCALSTACK = new GenericContainer(DockerImageName.parse("localstack/localstack:4.2.0")) .withExposedPorts(4566) diff --git a/dd-java-agent/instrumentation/aws-java/aws-java-eventbridge-2.0/src/test/groovy/EventBridgeClientTest.groovy b/dd-java-agent/instrumentation/aws-java/aws-java-eventbridge-2.0/src/test/groovy/EventBridgeClientTest.groovy index 3362d3f4b97..98f2ae37dcf 100644 --- a/dd-java-agent/instrumentation/aws-java/aws-java-eventbridge-2.0/src/test/groovy/EventBridgeClientTest.groovy +++ b/dd-java-agent/instrumentation/aws-java/aws-java-eventbridge-2.0/src/test/groovy/EventBridgeClientTest.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import datadog.trace.agent.test.InstrumentationSpecification import datadog.trace.api.DDSpanTypes import datadog.trace.api.config.GeneralConfig @@ -19,6 +22,9 @@ import software.amazon.awssdk.services.sqs.SqsClient import software.amazon.awssdk.services.sqs.model.QueueAttributeName import spock.lang.Shared +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) class EventBridgeClientTest extends InstrumentationSpecification { static final LOCALSTACK = new GenericContainer(DockerImageName.parse("localstack/localstack:4.2.0")) .withExposedPorts(4566) diff --git a/dd-java-agent/instrumentation/aws-java/aws-java-s3-2.0/src/test/groovy/S3ClientTest.groovy b/dd-java-agent/instrumentation/aws-java/aws-java-s3-2.0/src/test/groovy/S3ClientTest.groovy index c2071fb7858..10081f54b36 100644 --- a/dd-java-agent/instrumentation/aws-java/aws-java-s3-2.0/src/test/groovy/S3ClientTest.groovy +++ b/dd-java-agent/instrumentation/aws-java/aws-java-s3-2.0/src/test/groovy/S3ClientTest.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import datadog.trace.agent.test.InstrumentationSpecification import datadog.trace.api.DDSpanTypes import datadog.trace.api.DDTraceId @@ -17,6 +20,9 @@ import spock.lang.Shared import java.time.Duration +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) class S3ClientTest extends InstrumentationSpecification { static final LOCALSTACK = new GenericContainer(DockerImageName.parse("localstack/localstack:4.2.0")) .withExposedPorts(4566) diff --git a/dd-java-agent/instrumentation/aws-java/aws-java-sdk-2.2/src/payloadTaggingTest/groovy/PayloadTaggingTest.groovy b/dd-java-agent/instrumentation/aws-java/aws-java-sdk-2.2/src/payloadTaggingTest/groovy/PayloadTaggingTest.groovy index 2f8a4ee613e..5611b3e527e 100644 --- a/dd-java-agent/instrumentation/aws-java/aws-java-sdk-2.2/src/payloadTaggingTest/groovy/PayloadTaggingTest.groovy +++ b/dd-java-agent/instrumentation/aws-java/aws-java-sdk-2.2/src/payloadTaggingTest/groovy/PayloadTaggingTest.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import datadog.trace.agent.test.InstrumentationSpecification import datadog.trace.agent.test.utils.TraceUtils import datadog.trace.api.Config @@ -22,6 +25,9 @@ import java.time.Duration import static datadog.trace.agent.test.utils.TraceUtils.basicSpan +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class AbstractPayloadTaggingTest extends InstrumentationSpecification { static final Object NA = {} @@ -364,4 +370,3 @@ class PayloadTaggingMaxTagsForkedTest extends AbstractPayloadTaggingTest { ] } } - diff --git a/dd-java-agent/instrumentation/aws-java/aws-java-sfn-2.0/src/test/groovy/SfnClientTest.groovy b/dd-java-agent/instrumentation/aws-java/aws-java-sfn-2.0/src/test/groovy/SfnClientTest.groovy index 18b2c36ee06..7ec99fab635 100644 --- a/dd-java-agent/instrumentation/aws-java/aws-java-sfn-2.0/src/test/groovy/SfnClientTest.groovy +++ b/dd-java-agent/instrumentation/aws-java/aws-java-sfn-2.0/src/test/groovy/SfnClientTest.groovy @@ -1,5 +1,6 @@ import static datadog.trace.agent.test.utils.TraceUtils.basicSpan +import datadog.environment.OperatingSystem import datadog.trace.agent.test.naming.VersionedNamingTestBase import datadog.trace.agent.test.utils.TraceUtils import datadog.trace.api.DDSpanId @@ -15,8 +16,12 @@ import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.sfn.SfnClient import software.amazon.awssdk.services.sfn.model.SfnException import software.amazon.awssdk.services.sfn.model.StartExecutionResponse +import spock.lang.IgnoreIf import spock.lang.Shared +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class SfnClientTest extends VersionedNamingTestBase { @Shared GenericContainer localStack @Shared SfnClient sfnClient diff --git a/dd-java-agent/instrumentation/aws-java/aws-java-sns-1.0/src/test/groovy/SnsClientTest.groovy b/dd-java-agent/instrumentation/aws-java/aws-java-sns-1.0/src/test/groovy/SnsClientTest.groovy index 4aa1e6e2ddc..0dbaece205b 100644 --- a/dd-java-agent/instrumentation/aws-java/aws-java-sns-1.0/src/test/groovy/SnsClientTest.groovy +++ b/dd-java-agent/instrumentation/aws-java/aws-java-sns-1.0/src/test/groovy/SnsClientTest.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import com.amazonaws.auth.AWSStaticCredentialsProvider import com.amazonaws.auth.BasicAWSCredentials import com.amazonaws.client.builder.AwsClientBuilder @@ -23,6 +26,9 @@ import software.amazon.awssdk.services.sqs.SqsClient import software.amazon.awssdk.services.sqs.model.QueueAttributeName import spock.lang.Shared +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class SnsClientTest extends VersionedNamingTestBase { static final LOCALSTACK = new GenericContainer(DockerImageName.parse("localstack/localstack:4.2.0")) diff --git a/dd-java-agent/instrumentation/aws-java/aws-java-sns-2.0/src/test/groovy/SnsClientTest.groovy b/dd-java-agent/instrumentation/aws-java/aws-java-sns-2.0/src/test/groovy/SnsClientTest.groovy index d340515d7a1..e46e2ad3648 100644 --- a/dd-java-agent/instrumentation/aws-java/aws-java-sns-2.0/src/test/groovy/SnsClientTest.groovy +++ b/dd-java-agent/instrumentation/aws-java/aws-java-sns-2.0/src/test/groovy/SnsClientTest.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import datadog.trace.agent.test.naming.VersionedNamingTestBase import datadog.trace.agent.test.utils.TraceUtils import datadog.trace.api.DDSpanTypes @@ -22,6 +25,9 @@ import java.time.Duration import static datadog.trace.agent.test.utils.TraceUtils.basicSpan +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class SnsClientTest extends VersionedNamingTestBase { static final LOCALSTACK = new GenericContainer(DockerImageName.parse("localstack/localstack:4.2.0")) .withExposedPorts(4566) // Default LocalStack port diff --git a/dd-java-agent/instrumentation/aws-java/aws-java-sqs-2.0/build.gradle b/dd-java-agent/instrumentation/aws-java/aws-java-sqs-2.0/build.gradle index 734a548061c..6bba447fdba 100644 --- a/dd-java-agent/instrumentation/aws-java/aws-java-sqs-2.0/build.gradle +++ b/dd-java-agent/instrumentation/aws-java/aws-java-sqs-2.0/build.gradle @@ -1,3 +1,5 @@ +import datadog.gradle.plugin.HostPlatform + plugins { id 'dd-trace-java.module.instrumentation' } @@ -16,6 +18,12 @@ muzzle { addTestSuiteForDir('latestDepTest', 'test') addTestSuiteExtendingForDir('latestDepForkedTest', 'latestDepTest', 'test') +tasks.withType(Test).configureEach { + onlyIf('Embedded ElasticMQ tests exceed the task timeout in the Windows CI container') { + !HostPlatform.isWindows() + } +} + dependencies { compileOnly group: 'software.amazon.awssdk', name: 'sqs', version: '2.2.0' compileOnly group: 'com.amazonaws', name: 'amazon-sqs-java-messaging-lib', version: '2.0.0' diff --git a/dd-java-agent/instrumentation/couchbase/couchbase-3.1/src/test/groovy/CouchbaseClient31Test.groovy b/dd-java-agent/instrumentation/couchbase/couchbase-3.1/src/test/groovy/CouchbaseClient31Test.groovy index e37cda84896..4289fa95e26 100644 --- a/dd-java-agent/instrumentation/couchbase/couchbase-3.1/src/test/groovy/CouchbaseClient31Test.groovy +++ b/dd-java-agent/instrumentation/couchbase/couchbase-3.1/src/test/groovy/CouchbaseClient31Test.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import com.couchbase.client.core.env.TimeoutConfig import com.couchbase.client.core.error.DocumentNotFoundException import com.couchbase.client.core.error.ParsingFailureException @@ -26,6 +29,9 @@ import spock.lang.Shared import static datadog.trace.agent.test.utils.TraceUtils.basicSpan import static datadog.trace.agent.test.utils.TraceUtils.runUnderTrace +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class CouchbaseClient31Test extends VersionedNamingTestBase { static final String BUCKET = 'test-bucket' diff --git a/dd-java-agent/instrumentation/couchbase/couchbase-3.2/src/test/groovy/CouchbaseClient32Test.groovy b/dd-java-agent/instrumentation/couchbase/couchbase-3.2/src/test/groovy/CouchbaseClient32Test.groovy index ff7e9968cc8..70e719b2e7b 100644 --- a/dd-java-agent/instrumentation/couchbase/couchbase-3.2/src/test/groovy/CouchbaseClient32Test.groovy +++ b/dd-java-agent/instrumentation/couchbase/couchbase-3.2/src/test/groovy/CouchbaseClient32Test.groovy @@ -14,6 +14,7 @@ import com.couchbase.client.java.ClusterOptions import com.couchbase.client.java.env.ClusterEnvironment import com.couchbase.client.java.json.JsonObject import com.couchbase.client.java.query.QueryOptions +import datadog.environment.OperatingSystem import datadog.trace.agent.test.asserts.TraceAssert import datadog.trace.agent.test.naming.VersionedNamingTestBase import datadog.trace.api.Config @@ -29,10 +30,14 @@ import org.slf4j.LoggerFactory import org.testcontainers.couchbase.BucketDefinition import org.testcontainers.couchbase.CouchbaseContainer import reactor.core.publisher.Mono +import spock.lang.IgnoreIf import spock.lang.Shared import java.time.Duration +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class CouchbaseClient32Test extends VersionedNamingTestBase { static final String BUCKET = 'test-bucket' static final Logger LOGGER = LoggerFactory.getLogger(CouchbaseClient32Test) diff --git a/dd-java-agent/instrumentation/google-pubsub-1.116/src/test/groovy/PubSubTest.groovy b/dd-java-agent/instrumentation/google-pubsub-1.116/src/test/groovy/PubSubTest.groovy index 3317485f031..42791e23c85 100644 --- a/dd-java-agent/instrumentation/google-pubsub-1.116/src/test/groovy/PubSubTest.groovy +++ b/dd-java-agent/instrumentation/google-pubsub-1.116/src/test/groovy/PubSubTest.groovy @@ -19,6 +19,7 @@ import com.google.pubsub.v1.PubsubMessage import com.google.pubsub.v1.PushConfig import com.google.pubsub.v1.SubscriptionName import com.google.pubsub.v1.TopicName +import datadog.environment.OperatingSystem import datadog.trace.agent.test.asserts.TraceAssert import datadog.trace.agent.test.naming.VersionedNamingTestBase import datadog.trace.agent.test.utils.TraceUtils @@ -37,8 +38,12 @@ import java.nio.charset.StandardCharsets import java.util.concurrent.CountDownLatch import org.testcontainers.containers.PubSubEmulatorContainer import org.testcontainers.utility.DockerImageName +import spock.lang.IgnoreIf import spock.lang.Shared +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class PubSubTest extends VersionedNamingTestBase { private static final String PROJECT_ID = "dd-trace-java" diff --git a/dd-java-agent/instrumentation/java/java-io-1.8/src/test/groovy/datadog/trace/instrumentation/java/io/FileIORaspHelperForkedTest.groovy b/dd-java-agent/instrumentation/java/java-io-1.8/src/test/groovy/datadog/trace/instrumentation/java/io/FileIORaspHelperForkedTest.groovy index a2b0f6a93df..5276627ece2 100644 --- a/dd-java-agent/instrumentation/java/java-io-1.8/src/test/groovy/datadog/trace/instrumentation/java/io/FileIORaspHelperForkedTest.groovy +++ b/dd-java-agent/instrumentation/java/java-io-1.8/src/test/groovy/datadog/trace/instrumentation/java/io/FileIORaspHelperForkedTest.groovy @@ -8,6 +8,7 @@ import datadog.trace.instrumentation.java.lang.FileIORaspHelper import java.util.function.BiFunction import static datadog.trace.api.gateway.Events.EVENTS +import static datadog.trace.test.util.PlatformTestUtils.normalizePathSeparators class FileIORaspHelperForkedTest extends BaseIoRaspCallSiteTest { @@ -23,7 +24,9 @@ class FileIORaspHelperForkedTest extends BaseIoRaspCallSiteTest { then: 1 * callbackProvider.getCallback(EVENTS.fileLoaded()) >> listener - 1 * listener.apply(reqCtx, expected) >> flow + 1 * listener.apply(reqCtx, { actual -> + normalizePathSeparators(actual) == expected + }) >> flow where: args | expected diff --git a/dd-java-agent/instrumentation/java/java-lang/java-lang-1.8/src/test/groovy/datadog/trace/instrumentation/java/lang/ProcessImplInstrumentationSpecification.groovy b/dd-java-agent/instrumentation/java/java-lang/java-lang-1.8/src/test/groovy/datadog/trace/instrumentation/java/lang/ProcessImplInstrumentationSpecification.groovy index b54492e2e6f..ad6fe62582d 100644 --- a/dd-java-agent/instrumentation/java/java-lang/java-lang-1.8/src/test/groovy/datadog/trace/instrumentation/java/lang/ProcessImplInstrumentationSpecification.groovy +++ b/dd-java-agent/instrumentation/java/java-lang/java-lang-1.8/src/test/groovy/datadog/trace/instrumentation/java/lang/ProcessImplInstrumentationSpecification.groovy @@ -1,13 +1,18 @@ package datadog.trace.instrumentation.java.lang +import datadog.environment.OperatingSystem import datadog.trace.agent.test.InstrumentationSpecification import datadog.trace.agent.test.asserts.SpanAssert import datadog.trace.agent.test.utils.TraceUtils import datadog.trace.api.Config import datadog.trace.bootstrap.ActiveSubsystems +import spock.lang.IgnoreIf import java.util.concurrent.TimeUnit +@IgnoreIf(reason = "Uses POSIX /bin/sh commands, which are not available on Windows", value = { + OperatingSystem.isWindows() +}) class ProcessImplInstrumentationSpecification extends InstrumentationSpecification { boolean previousAppsecState = false diff --git a/dd-java-agent/instrumentation/jdbc/src/test/groovy/RemoteJDBCInstrumentationTest.groovy b/dd-java-agent/instrumentation/jdbc/src/test/groovy/RemoteJDBCInstrumentationTest.groovy index da27c427158..0f980d18f34 100644 --- a/dd-java-agent/instrumentation/jdbc/src/test/groovy/RemoteJDBCInstrumentationTest.groovy +++ b/dd-java-agent/instrumentation/jdbc/src/test/groovy/RemoteJDBCInstrumentationTest.groovy @@ -38,6 +38,7 @@ import org.testcontainers.containers.MySQLContainer import org.testcontainers.containers.OracleContainer import org.testcontainers.containers.PostgreSQLContainer import org.testcontainers.utility.DockerImageName +import spock.lang.IgnoreIf import spock.lang.Shared enum DbType { @@ -58,6 +59,12 @@ enum DbType { } } +@IgnoreIf( +reason = "The Windows CI container does not provide a Docker environment capable of running Testcontainers", +inherited = true, +value = { + OperatingSystem.isWindows() +}) abstract class RemoteJDBCInstrumentationTest extends VersionedNamingTestBase { @Shared private Map dbName = [ diff --git a/dd-java-agent/instrumentation/jms/javax-jms-1.1/src/test/groovy/JMS1Test.groovy b/dd-java-agent/instrumentation/jms/javax-jms-1.1/src/test/groovy/JMS1Test.groovy index f24b7e8f9ff..f71c33b265a 100644 --- a/dd-java-agent/instrumentation/jms/javax-jms-1.1/src/test/groovy/JMS1Test.groovy +++ b/dd-java-agent/instrumentation/jms/javax-jms-1.1/src/test/groovy/JMS1Test.groovy @@ -1,6 +1,7 @@ import static datadog.trace.api.config.TraceInstrumentationConfig.LEGACY_CONTEXT_MANAGER_ENABLED import static org.junit.jupiter.api.Assumptions.assumeTrue +import datadog.environment.OperatingSystem import datadog.trace.agent.test.asserts.ListWriterAssert import datadog.trace.agent.test.asserts.TraceAssert import datadog.trace.agent.test.naming.VersionedNamingTestBase @@ -32,6 +33,7 @@ import javax.jms.TopicSession import jms10mock.Jms10ConnectionFactory import org.apache.activemq.command.ActiveMQTextMessage import org.apache.activemq.junit.EmbeddedActiveMQBroker +import spock.lang.IgnoreIf import spock.lang.Shared abstract class JMS1Test extends VersionedNamingTestBase { @@ -120,6 +122,9 @@ abstract class JMS1Test extends VersionedNamingTestBase { } } + @IgnoreIf( + reason = "Windows scheduling can finish the third consume trace before the intermediate five-trace assertion", + value = { OperatingSystem.isWindows() }) def "sending messages to #destinationType generates spans"() { setup: def destination = destinationType.create(session) diff --git a/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/build.gradle b/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/build.gradle index 10c111c9aed..80aacbe4b80 100644 --- a/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/build.gradle +++ b/dd-java-agent/instrumentation/kafka/kafka-clients-0.11/build.gradle @@ -1,3 +1,5 @@ +import datadog.gradle.plugin.HostPlatform + plugins { id 'dd-trace-java.module.instrumentation' } @@ -24,6 +26,12 @@ muzzle { addTestSuite('latestDepTest') addTestSuite('iastLatestDepTest3') +tasks.named('forkedTest', Test) { + onlyIf('Embedded Kafka leaves log files locked and exceeds the task timeout on Windows') { + !HostPlatform.isWindows() + } +} + dependencies { compileOnly group: 'org.apache.kafka', name: 'kafka-clients', version: '0.11.0.0' implementation project(':dd-java-agent:instrumentation:kafka:kafka-common') @@ -71,4 +79,3 @@ tasks.named("iastLatestDepTest3", Test) { javaLauncher = getJavaLauncherFor(17) jvmArgs = ['--add-opens', 'java.base/java.util=ALL-UNNAMED'] } - diff --git a/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/test/groovy/KafkaClientTestBase.groovy b/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/test/groovy/KafkaClientTestBase.groovy index 579532664bd..2451f8ee88e 100644 --- a/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/test/groovy/KafkaClientTestBase.groovy +++ b/dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/test/groovy/KafkaClientTestBase.groovy @@ -1,3 +1,4 @@ +import datadog.environment.OperatingSystem import datadog.trace.agent.test.InstrumentationSpecification import datadog.trace.agent.test.asserts.TraceAssert import datadog.trace.agent.test.naming.VersionedNamingTestBase @@ -12,6 +13,7 @@ import datadog.trace.common.writer.ListWriter import datadog.trace.core.DDSpan import datadog.trace.core.datastreams.StatsGroup import datadog.trace.instrumentation.kafka_common.ClusterIdHolder +import datadog.trace.test.util.PollingConditions import org.apache.kafka.clients.consumer.ConsumerConfig import org.apache.kafka.clients.consumer.ConsumerRecord import org.apache.kafka.clients.consumer.KafkaConsumer @@ -229,6 +231,7 @@ abstract class KafkaClientTestBase extends VersionedNamingTestBase { ClusterIdHolder.get() == null int nTraces = isDataStreamsEnabled() ? 3 : 2 int produceTraceIdx = nTraces - 1 + normalizeTraceChunks() TEST_WRITER.waitForTraces(nTraces) def traces = new ArrayList<>(TEST_WRITER) traces.sort(new SortKafkaTraces()) @@ -384,6 +387,7 @@ abstract class KafkaClientTestBase extends VersionedNamingTestBase { received.value() == greeting received.key() == null + normalizeTraceChunks() assertTraces(2, SORT_TRACES_BY_ID) { trace(3) { basicSpan(it, "parent") @@ -455,6 +459,38 @@ abstract class KafkaClientTestBase extends VersionedNamingTestBase { container?.stop() } + /** + * On Windows, thread scheduling can publish the Kafka deliver parent and consumer child as + * separate chunks. Reassemble them into the trace shape asserted on other platforms. + */ + protected void normalizeTraceChunks() { + if (!OperatingSystem.isWindows()) { + return + } + + new PollingConditions(timeout: 20).eventually { + assert TEST_WRITER.flatten().any { + it.operationName.toString() == operationForConsumer() + } + } + + TEST_WRITER.groupBy { trace -> [trace.first().traceId, trace.first().localRootSpan.spanId] } + .values() + .findAll { chunks -> + chunks.size() > 1 && + chunks.first().first().localRootSpan.operationName.toString() == "kafka.deliver" + } + .each { chunks -> + def localRootSpan = chunks.first().first().localRootSpan + def normalizedTrace = chunks + .collectMany { chunk -> chunk } + .sort { span -> span.spanId == localRootSpan.spanId ? 1 : 0 } + int normalizedIndex = TEST_WRITER.indexOf(chunks.first()) + TEST_WRITER.set(normalizedIndex, normalizedTrace) + chunks.tail().each { TEST_WRITER.remove(it) } + } + } + def "test pass through tombstone"() { setup: diff --git a/dd-java-agent/instrumentation/lettuce/lettuce-5.0/src/test/groovy/Lettuce5ClientTestBase.groovy b/dd-java-agent/instrumentation/lettuce/lettuce-5.0/src/test/groovy/Lettuce5ClientTestBase.groovy index 9749dbdfb0f..a593c03b044 100644 --- a/dd-java-agent/instrumentation/lettuce/lettuce-5.0/src/test/groovy/Lettuce5ClientTestBase.groovy +++ b/dd-java-agent/instrumentation/lettuce/lettuce-5.0/src/test/groovy/Lettuce5ClientTestBase.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import com.redis.testcontainers.RedisContainer import datadog.trace.agent.test.naming.VersionedNamingTestBase import datadog.trace.agent.test.utils.PortUtils @@ -14,6 +17,9 @@ import spock.util.concurrent.PollingConditions import static datadog.trace.agent.test.utils.TraceUtils.runUnderTrace +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class Lettuce5ClientTestBase extends VersionedNamingTestBase { public static final int DB_INDEX = 0 // Disable autoreconnect so we do not get stray traces popping up on server shutdown diff --git a/dd-java-agent/instrumentation/lettuce/lettuce-5.0/src/test/java/Lettuce5MasterReplicaTest.java b/dd-java-agent/instrumentation/lettuce/lettuce-5.0/src/test/java/Lettuce5MasterReplicaTest.java index 40d8564e34c..67cef961f37 100644 --- a/dd-java-agent/instrumentation/lettuce/lettuce-5.0/src/test/java/Lettuce5MasterReplicaTest.java +++ b/dd-java-agent/instrumentation/lettuce/lettuce-5.0/src/test/java/Lettuce5MasterReplicaTest.java @@ -23,6 +23,9 @@ import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.utility.DockerImageName; +@org.junit.jupiter.api.condition.DisabledOnOs( + value = org.junit.jupiter.api.condition.OS.WINDOWS, + disabledReason = "Requires a Docker environment capable of running Linux Testcontainers") class Lettuce5MasterReplicaTest extends AbstractInstrumentationTest { private RedisContainer redisServer; private RedisClient redisClient; diff --git a/dd-java-agent/instrumentation/maven/maven-3.2.1/build.gradle b/dd-java-agent/instrumentation/maven/maven-3.2.1/build.gradle index 2023f3de408..11901a1bafc 100644 --- a/dd-java-agent/instrumentation/maven/maven-3.2.1/build.gradle +++ b/dd-java-agent/instrumentation/maven/maven-3.2.1/build.gradle @@ -1,3 +1,5 @@ +import datadog.gradle.plugin.HostPlatform + plugins { id 'dd-trace-java.module.instrumentation' } @@ -13,6 +15,12 @@ muzzle { addTestSuiteForDir('latestDepTest', 'test') +tasks.named('latestDepTest', Test) { + onlyIf('Embedded Maven latest-dependency tests do not terminate on Windows') { + !HostPlatform.isWindows() + } +} + dependencies { compileOnly 'org.apache.maven:maven-embedder:3.2.1' diff --git a/dd-java-agent/instrumentation/maven/maven-3.2.1/src/test/java/datadog/trace/instrumentation/maven3/MavenUtilsTest.java b/dd-java-agent/instrumentation/maven/maven-3.2.1/src/test/java/datadog/trace/instrumentation/maven3/MavenUtilsTest.java index 6d7fad46e8a..ba693a2a163 100644 --- a/dd-java-agent/instrumentation/maven/maven-3.2.1/src/test/java/datadog/trace/instrumentation/maven3/MavenUtilsTest.java +++ b/dd-java-agent/instrumentation/maven/maven-3.2.1/src/test/java/datadog/trace/instrumentation/maven3/MavenUtilsTest.java @@ -1,5 +1,7 @@ package datadog.trace.instrumentation.maven3; +import static datadog.trace.test.util.PlatformTestUtils.normalizeExecutableName; +import static datadog.trace.test.util.PlatformTestUtils.normalizePathSeparators; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -286,7 +288,9 @@ private boolean assertGetEffectiveJvmFallbackUsesToolchains(ExecutionEvent execu MavenSession session = executionEvent.getSession(); String effectiveJvm = MavenUtils.getEffectiveJvmFallback(session, mojoExecution); assertNotNull(effectiveJvm); - assertTrue(effectiveJvm.endsWith("/my-jdk-home/bin/java")); + assertTrue( + normalizeExecutableName(normalizePathSeparators(effectiveJvm)) + .endsWith("/my-jdk-home/bin/java")); return true; } @@ -331,7 +335,7 @@ private boolean assertGetForkedJvmPath(ExecutionEvent executionEvent) { MavenSession session = executionEvent.getSession(); Path jvmPath = MavenUtils.getForkedJvmPath(session, mojoExecution); assertNotNull(jvmPath); - assertTrue(jvmPath.toString().endsWith("/java")); + assertEquals("java", normalizeExecutableName(jvmPath.getFileName().toString())); return true; } @@ -356,7 +360,10 @@ private void assertClasspath(Collection classpath, String... suffixes) { for (String suffix : suffixes) { assertFalse( - classpath.stream().noneMatch(c -> c.toString().endsWith(suffix)), + classpath.stream() + .map(Path::toString) + .map(c -> normalizePathSeparators(c)) + .noneMatch(c -> c.endsWith(suffix)), "Missing entry: " + suffix); } } diff --git a/dd-java-agent/instrumentation/mongo/mongo-common/src/testFixtures/groovy/MongoBaseTest.groovy b/dd-java-agent/instrumentation/mongo/mongo-common/src/testFixtures/groovy/MongoBaseTest.groovy index 55f686f712b..3a3bba08f41 100644 --- a/dd-java-agent/instrumentation/mongo/mongo-common/src/testFixtures/groovy/MongoBaseTest.groovy +++ b/dd-java-agent/instrumentation/mongo/mongo-common/src/testFixtures/groovy/MongoBaseTest.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import datadog.trace.agent.test.asserts.TraceAssert import datadog.trace.agent.test.naming.VersionedNamingTestBase import datadog.trace.api.Config @@ -9,6 +12,9 @@ import org.slf4j.LoggerFactory import org.testcontainers.containers.MongoDBContainer import spock.lang.Shared +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class MongoBaseTest extends VersionedNamingTestBase { public static final String V0_DB_TYPE = "mongo" public static final String V0_SERVICE = "mongo" diff --git a/dd-java-agent/instrumentation/mule-4.5/build.gradle b/dd-java-agent/instrumentation/mule-4.5/build.gradle index 3e305f0dabb..eb4645a60f5 100644 --- a/dd-java-agent/instrumentation/mule-4.5/build.gradle +++ b/dd-java-agent/instrumentation/mule-4.5/build.gradle @@ -1,3 +1,5 @@ +import datadog.gradle.plugin.HostPlatform + plugins { id 'dd-trace-java.module.instrumentation' id 'idea' @@ -231,7 +233,6 @@ tasks.register('mvnPackage', Exec) { environment["JAVA_HOME"] = getLazyJavaHomeFor(8) List mvnArgs = [ - "$rootDir/mvnw", "-Ddatadog.builddir=$buildDir", "-Ddatadog.name=mule-test-application", "-Ddatadog.version=$version", @@ -240,10 +241,10 @@ tasks.register('mvnPackage', Exec) { // Specify caches folder on CI. if (providers.environmentVariable("CI").isPresent()) { - mvnArgs.add(1, "-Dmaven.repo.local=$rootDir/.mvn/caches") + mvnArgs.add(0, "-Dmaven.repo.local=$rootDir/.mvn/caches") } - commandLine(mvnArgs) + commandLine(HostPlatform.mavenWrapperCommand(rootDir, mvnArgs)) outputs.dir("$buildDir/target") inputs.dir("$appDir/src") diff --git a/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/ChatCompletionServiceTest.groovy b/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/ChatCompletionServiceTest.groovy index 0a8553e7930..1caec33b3ba 100644 --- a/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/ChatCompletionServiceTest.groovy +++ b/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/ChatCompletionServiceTest.groovy @@ -365,6 +365,7 @@ class ChatCompletionServiceTest extends OpenAiTest { def expectedMetadata = new LinkedHashMap(metadata) expectedMetadata.putIfAbsent("stream", isStreaming) + waitForTraces() assertTraces(1) { trace(3) { sortSpansByStart() diff --git a/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/CompletionServiceTest.groovy b/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/CompletionServiceTest.groovy index 50529ee02a6..28f876badde 100644 --- a/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/CompletionServiceTest.groovy +++ b/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/CompletionServiceTest.groovy @@ -143,6 +143,7 @@ class CompletionServiceTest extends OpenAiTest { List outputTagsOut = [] Map metadataOut = [:] + waitForTraces() assertTraces(1) { trace(3) { sortSpansByStart() diff --git a/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/EmbeddingServiceTest.groovy b/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/EmbeddingServiceTest.groovy index 41e341284f3..9ce99e5e2d6 100644 --- a/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/EmbeddingServiceTest.groovy +++ b/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/EmbeddingServiceTest.groovy @@ -43,6 +43,7 @@ class EmbeddingServiceTest extends OpenAiTest { List inputTagsOut = [] Map metadataOut = [:] + waitForTraces() assertTraces(1) { trace(3) { sortSpansByStart() diff --git a/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/GlobalTagsTest.groovy b/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/GlobalTagsTest.groovy index 420bf13c295..ac7fa4228e6 100644 --- a/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/GlobalTagsTest.groovy +++ b/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/GlobalTagsTest.groovy @@ -16,7 +16,7 @@ class GlobalTagsTest extends OpenAiTest { runUnderTrace("parent") { openAiClient.chat().completions().create(chatCompletionCreateParams(false)) } - TEST_WRITER.waitForTraces(1) + waitForTraces() def openAiSpan = TEST_WRITER.flatten().find { it.operationName.toString() == "openai.request" } then: diff --git a/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/OpenAiTest.groovy b/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/OpenAiTest.groovy index 3026dbe8a89..112cfc0583d 100644 --- a/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/OpenAiTest.groovy +++ b/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/OpenAiTest.groovy @@ -31,15 +31,25 @@ import com.openai.models.responses.Tool import com.openai.models.responses.ToolChoiceCustom import datadog.trace.agent.test.InstrumentationSpecification import datadog.trace.agent.test.server.http.TestHttpServer +import datadog.environment.OperatingSystem import datadog.trace.api.config.LlmObsConfig import datadog.trace.core.util.LRUCache import java.nio.file.Path import java.nio.file.Paths import spock.lang.AutoCleanup +import spock.lang.IgnoreIf import spock.lang.Shared +@IgnoreIf( +reason = "The first OpenAI request does not emit its trace on Windows CI", +inherited = true, +value = { + OperatingSystem.isWindows() +}) abstract class OpenAiTest extends InstrumentationSpecification { + private static final int WINDOWS_TRACE_TIMEOUT_SECONDS = 90 + // openai token - will use real openai backend and record request/responses to use later in the mock mode // empty or null - will use mockOpenAiBackend and read recorded request/responses static final String OPENAI_TOKEN = "" @@ -111,6 +121,19 @@ abstract class OpenAiTest extends InstrumentationSpecification { } } + /** + * A fresh OpenAI test process can take longer than the default 20-second trace timeout on + * Windows CI. Allow up to 90 seconds there while retaining the default timeout elsewhere. + */ + void waitForTraces(int count = 1) { + if (OperatingSystem.isWindows()) { + assert TEST_WRITER.waitForTracesMax(count, WINDOWS_TRACE_TIMEOUT_SECONDS): + "Timeout waiting for $count OpenAI trace(s)" + } else { + TEST_WRITER.waitForTraces(count) + } + } + void httpClientUrlIfExists(OkHttpClient.Builder httpClient, String url) { try { def method = httpClient.getClass().getMethod("baseUrl", String) diff --git a/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/ResponseServiceTest.groovy b/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/ResponseServiceTest.groovy index b22389a4d41..7ddbf5d3d95 100644 --- a/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/ResponseServiceTest.groovy +++ b/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/groovy/ResponseServiceTest.groovy @@ -366,6 +366,7 @@ class ResponseServiceTest extends OpenAiTest { then: List outputMessages = [] + waitForTraces() assertTraces(1) { trace(3) { sortSpansByStart() @@ -418,6 +419,7 @@ class ResponseServiceTest extends OpenAiTest { Map metadataOut, boolean expectPromptTag = false, List> toolDefinitionsOut = null) { + waitForTraces() assertTraces(1) { trace(3) { sortSpansByStart() diff --git a/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/java/datadog/trace/instrumentation/openai_java/LlmObsContextPropagationForkedTest.java b/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/java/datadog/trace/instrumentation/openai_java/LlmObsContextPropagationForkedTest.java index 66d4f6aa285..5c4ca2b9dd1 100644 --- a/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/java/datadog/trace/instrumentation/openai_java/LlmObsContextPropagationForkedTest.java +++ b/dd-java-agent/instrumentation/openai-java/openai-java-3.0/src/test/java/datadog/trace/instrumentation/openai_java/LlmObsContextPropagationForkedTest.java @@ -11,6 +11,7 @@ import com.openai.models.chat.completions.ChatCompletionCreateParams; import com.sun.net.httpserver.HttpServer; import datadog.context.ContextScope; +import datadog.environment.OperatingSystem; import datadog.trace.agent.test.AbstractInstrumentationTest; import datadog.trace.api.llmobs.LLMObsContext; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; @@ -23,6 +24,8 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; /** * Mock OpenAI backend and request helpers, shared by the LLMObs forked tests in this file. @@ -33,6 +36,8 @@ */ abstract class AbstractLlmObsOpenAiForkedTest extends AbstractInstrumentationTest { + private static final int WINDOWS_TRACE_TIMEOUT_SECONDS = 90; + protected static HttpServer mockServer; protected static OpenAIClient openAiClient; @@ -83,6 +88,20 @@ protected static DDSpan findSpanByOperationName(List> traces, Strin .findFirst() .orElse(null); } + + /** + * A fresh OpenAI test process can take longer than the default 20-second trace timeout on Windows + * CI. Allow up to 90 seconds there while retaining the default timeout elsewhere. + */ + protected void waitForTraces(int count) throws Exception { + if (OperatingSystem.isWindows()) { + if (!writer.waitForTracesMax(count, WINDOWS_TRACE_TIMEOUT_SECONDS)) { + throw new AssertionError("Timeout waiting for " + count + " OpenAI trace(s)"); + } + } else { + writer.waitForTraces(count); + } + } } /** @@ -101,6 +120,9 @@ protected static DDSpan findSpanByOperationName(List> traces, Strin * body shape doesn't matter for what's being tested. */ @WithConfig(key = "llmobs.enabled", value = "true") +@DisabledOnOs( + value = OS.WINDOWS, + disabledReason = "The first OpenAI request does not emit its trace on Windows CI") class LlmObsContextPropagationForkedTest extends AbstractLlmObsOpenAiForkedTest { @Test @@ -122,7 +144,7 @@ void openAiRequestSpanInheritsSessionIdFromActiveContext() throws Exception { parentSpan.finish(); } - writer.waitForTraces(1); + waitForTraces(1); DDSpan openAiSpan = findSpanByOperationName(writer, "openai.request"); assertNotNull(openAiSpan, "openai.request span should have been created"); assertEquals(expectedSessionId, openAiSpan.getTag("_ml_obs_tag.session_id")); @@ -137,7 +159,7 @@ void openAiRequestSpanHasNoSessionIdWhenNoLlmObsContext() throws Exception { // is already created by the instrumentation advice before this point. } - writer.waitForTraces(1); + waitForTraces(1); DDSpan openAiSpan = findSpanByOperationName(writer, "openai.request"); assertNotNull(openAiSpan, "openai.request span should have been created"); assertNull(openAiSpan.getTag("_ml_obs_tag.session_id")); @@ -162,7 +184,7 @@ void openAiRequestSpanInheritsAgentVersionFromActiveContext() throws Exception { parentSpan.finish(); } - writer.waitForTraces(1); + waitForTraces(1); DDSpan openAiSpan = findSpanByOperationName(writer, "openai.request"); assertNotNull(openAiSpan, "openai.request span should have been created"); assertEquals(expectedAgentVersion, openAiSpan.getTag("_ml_obs_tag.agent_version")); @@ -190,7 +212,7 @@ void openAiRequestSpanInheritsDroppedSamplingDecisionFromActiveContext() throws parentSpan.finish(); } - writer.waitForTraces(1); + waitForTraces(1); DDSpan openAiSpan = findSpanByOperationName(writer, "openai.request"); assertNotNull(openAiSpan, "openai.request span should have been created"); assertEquals( @@ -221,7 +243,7 @@ void openAiRequestSpanInheritsRetainedSamplingDecisionFromActiveContext() throws parentSpan.finish(); } - writer.waitForTraces(1); + waitForTraces(1); DDSpan openAiSpan = findSpanByOperationName(writer, "openai.request"); assertNotNull(openAiSpan, "openai.request span should have been created"); assertEquals( @@ -240,7 +262,7 @@ void openAiRequestSpanComputesItsOwnSamplingDecisionWhenNoLlmObsContext() throws // No verdict to inherit, so the span is the root of its own LLMObs trace and decides for // itself. The rate of 1.0 retains every trace ID, so the verdict is deterministic without // controlling the trace ID. - writer.waitForTraces(1); + waitForTraces(1); DDSpan openAiSpan = findSpanByOperationName(writer, "openai.request"); assertNotNull(openAiSpan, "openai.request span should have been created"); assertEquals( @@ -272,7 +294,7 @@ void openAiRequestSpanInheritsNothingFromStaleCrossTraceContext() throws Excepti staleParent.finish(); } - writer.waitForTraces(2); + waitForTraces(2); DDSpan openAiSpan = findSpanByOperationName(writer, "openai.request"); assertNotNull(openAiSpan, "openai.request span should have been created"); @@ -300,6 +322,9 @@ void openAiRequestSpanInheritsNothingFromStaleCrossTraceContext() throws Excepti */ @WithConfig(key = "llmobs.enabled", value = "true") @WithConfig(key = "llmobs.sample.rate", value = "0") +@DisabledOnOs( + value = OS.WINDOWS, + disabledReason = "The first OpenAI request does not emit its trace on Windows CI") class LlmObsZeroSampleRateForkedTest extends AbstractLlmObsOpenAiForkedTest { @Test @@ -309,7 +334,7 @@ void parentlessOpenAiRequestSpanIsDroppedAtZeroSampleRate() throws Exception { } catch (Exception ignored) { } - writer.waitForTraces(1); + waitForTraces(1); DDSpan openAiSpan = findSpanByOperationName(writer, "openai.request"); assertNotNull(openAiSpan, "openai.request span should have been created"); assertEquals( diff --git a/dd-java-agent/instrumentation/rabbitmq-amqp-2.7/src/reactorTest/groovy/ReactorRabbitMQTest.groovy b/dd-java-agent/instrumentation/rabbitmq-amqp-2.7/src/reactorTest/groovy/ReactorRabbitMQTest.groovy index da056550c8f..d0d80346367 100644 --- a/dd-java-agent/instrumentation/rabbitmq-amqp-2.7/src/reactorTest/groovy/ReactorRabbitMQTest.groovy +++ b/dd-java-agent/instrumentation/rabbitmq-amqp-2.7/src/reactorTest/groovy/ReactorRabbitMQTest.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import com.rabbitmq.client.AMQP import com.rabbitmq.client.Channel import com.rabbitmq.client.ConnectionFactory @@ -14,6 +17,9 @@ import spock.lang.Shared import java.time.Duration import java.util.concurrent.TimeUnit +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) class ReactorRabbitMQTest extends InstrumentationSpecification { @Shared def rabbitMQContainer diff --git a/dd-java-agent/instrumentation/rabbitmq-amqp-2.7/src/test/groovy/RabbitMQTest.groovy b/dd-java-agent/instrumentation/rabbitmq-amqp-2.7/src/test/groovy/RabbitMQTest.groovy index 966ab9d4b80..1ef81307b32 100644 --- a/dd-java-agent/instrumentation/rabbitmq-amqp-2.7/src/test/groovy/RabbitMQTest.groovy +++ b/dd-java-agent/instrumentation/rabbitmq-amqp-2.7/src/test/groovy/RabbitMQTest.groovy @@ -8,6 +8,7 @@ import com.rabbitmq.client.DefaultConsumer import com.rabbitmq.client.Envelope import com.rabbitmq.client.GetResponse import com.rabbitmq.client.ShutdownSignalException +import datadog.environment.OperatingSystem import datadog.trace.agent.test.asserts.TraceAssert import datadog.trace.agent.test.naming.VersionedNamingTestBase import datadog.trace.agent.test.utils.PortUtils @@ -26,6 +27,7 @@ import org.springframework.amqp.rabbit.connection.CachingConnectionFactory import org.springframework.amqp.rabbit.core.RabbitAdmin import org.springframework.amqp.rabbit.core.RabbitTemplate import org.testcontainers.containers.RabbitMQContainer +import spock.lang.IgnoreIf import spock.lang.Shared import spock.util.concurrent.PollingConditions @@ -39,6 +41,9 @@ import static datadog.trace.agent.test.utils.TraceUtils.runUnderTrace import static datadog.trace.api.config.TraceInstrumentationConfig.RABBIT_PROPAGATION_DISABLED_EXCHANGES import static datadog.trace.api.config.TraceInstrumentationConfig.RABBIT_PROPAGATION_DISABLED_QUEUES +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class RabbitMQTestBase extends VersionedNamingTestBase { @Shared def rabbitMQContainer diff --git a/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/RatpackOtherTest.groovy b/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/RatpackOtherTest.groovy index 1da9c15c2e7..6a6f4325274 100644 --- a/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/RatpackOtherTest.groovy +++ b/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/RatpackOtherTest.groovy @@ -8,6 +8,9 @@ import okhttp3.Request import ratpack.groovy.test.embed.GroovyEmbeddedApp import ratpack.path.PathBinding +import static datadog.trace.test.util.PlatformTestUtils.normalizeLocalhostHostname +import static datadog.trace.test.util.PlatformTestUtils.normalizeLocalhostUrl + class RatpackOtherTest extends InstrumentationSpecification { OkHttpClient client = OkHttpUtils.client() @@ -73,8 +76,14 @@ class RatpackOtherTest extends InstrumentationSpecification { "$Tags.SPAN_KIND" Tags.SPAN_KIND_SERVER "$Tags.PEER_HOST_IPV4" "127.0.0.1" "$Tags.PEER_PORT" Integer - "$Tags.HTTP_URL" "${app.address.resolve(path)}" - "$Tags.HTTP_HOSTNAME" "${app.address.host}" + "$Tags.HTTP_URL" { + normalizeLocalhostUrl(it as String) == + normalizeLocalhostUrl("${app.address.resolve(path)}") + } + "$Tags.HTTP_HOSTNAME" { + normalizeLocalhostHostname(it as String) == + normalizeLocalhostHostname("${app.address.host}") + } "$Tags.HTTP_METHOD" "GET" "$Tags.HTTP_STATUS" 200 "$Tags.HTTP_ROUTE" "/$route" @@ -95,8 +104,14 @@ class RatpackOtherTest extends InstrumentationSpecification { "$Tags.SPAN_KIND" Tags.SPAN_KIND_SERVER "$Tags.PEER_HOST_IPV4" "127.0.0.1" "$Tags.PEER_PORT" Integer - "$Tags.HTTP_URL" "${app.address.resolve(path)}" - "$Tags.HTTP_HOSTNAME" "${app.address.host}" + "$Tags.HTTP_URL" { + normalizeLocalhostUrl(it as String) == + normalizeLocalhostUrl("${app.address.resolve(path)}") + } + "$Tags.HTTP_HOSTNAME" { + normalizeLocalhostHostname(it as String) == + normalizeLocalhostHostname("${app.address.host}") + } "$Tags.HTTP_METHOD" "GET" "$Tags.HTTP_STATUS" 200 "$Tags.HTTP_ROUTE" "/$route" diff --git a/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackHttpServerTest.groovy b/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackHttpServerTest.groovy index 0b665683b2b..dc5c911ef0e 100644 --- a/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackHttpServerTest.groovy +++ b/dd-java-agent/instrumentation/ratpack-1.5/src/test/groovy/server/RatpackHttpServerTest.groovy @@ -15,6 +15,8 @@ import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.EXCEPT import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.FORWARDED import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.PATH_PARAM import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.SUCCESS +import static datadog.trace.test.util.PlatformTestUtils.normalizeLocalhostHostname +import static datadog.trace.test.util.PlatformTestUtils.normalizeLocalhostUrl class RatpackHttpServerTest extends HttpServerTest { @@ -33,6 +35,16 @@ class RatpackHttpServerTest extends HttpServerTest { "netty.request" } + @Override + String normalizeServerHostname(String value) { + normalizeLocalhostHostname(value) + } + + @Override + String normalizeServerUrl(String value) { + normalizeLocalhostUrl(value) + } + @Override String expectedResourceName(ServerEndpoint endpoint, String method, URI address) { if (endpoint == PATH_PARAM) { @@ -138,7 +150,10 @@ class RatpackHttpServerTest extends HttpServerTest { "$Tags.PEER_HOST_IPV4" "127.0.0.1" // This span ignores "x-forwards-from". "$Tags.PEER_PORT" Integer "$Tags.HTTP_URL" String - "$Tags.HTTP_HOSTNAME" "${address.host}" + "$Tags.HTTP_HOSTNAME" { + normalizeLocalhostHostname(it as String) == + normalizeLocalhostHostname("${address.host}") + } "$Tags.HTTP_METHOD" String "$Tags.HTTP_STATUS" Integer "$Tags.HTTP_ROUTE" String diff --git a/dd-java-agent/instrumentation/redisson/redisson-2.0.0/src/test/groovy/RedissonClientTest.groovy b/dd-java-agent/instrumentation/redisson/redisson-2.0.0/src/test/groovy/RedissonClientTest.groovy index 3201a5f6969..ddcd7a429b7 100644 --- a/dd-java-agent/instrumentation/redisson/redisson-2.0.0/src/test/groovy/RedissonClientTest.groovy +++ b/dd-java-agent/instrumentation/redisson/redisson-2.0.0/src/test/groovy/RedissonClientTest.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import org.testcontainers.utility.DockerImageName import static datadog.trace.api.config.TraceInstrumentationConfig.DB_CLIENT_HOST_SPLIT_BY_INSTANCE @@ -17,6 +20,9 @@ import org.redisson.client.protocol.RedisCommands import org.testcontainers.containers.wait.strategy.Wait import spock.lang.Shared +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class RedissonClientTest extends VersionedNamingTestBase { @Shared RedisServer redisServer = new RedisContainer(DockerImageName.parse("redis:6.2.6")).waitingFor(Wait.forListeningPort()) diff --git a/dd-java-agent/instrumentation/redisson/redisson-2.3.0/src/test/groovy/RedissonClientTest.groovy b/dd-java-agent/instrumentation/redisson/redisson-2.3.0/src/test/groovy/RedissonClientTest.groovy index d8e0c5bbc78..485d6fa0791 100644 --- a/dd-java-agent/instrumentation/redisson/redisson-2.3.0/src/test/groovy/RedissonClientTest.groovy +++ b/dd-java-agent/instrumentation/redisson/redisson-2.3.0/src/test/groovy/RedissonClientTest.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import com.redis.testcontainers.RedisContainer import com.redis.testcontainers.RedisServer import datadog.trace.agent.test.asserts.TraceAssert @@ -13,6 +16,9 @@ import spock.lang.Shared import static datadog.trace.api.config.TraceInstrumentationConfig.DB_CLIENT_HOST_SPLIT_BY_INSTANCE +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class RedissonClientTest extends VersionedNamingTestBase { @Shared diff --git a/dd-java-agent/instrumentation/redisson/redisson-3.10.3/src/test/groovy/RedissonClientTest.groovy b/dd-java-agent/instrumentation/redisson/redisson-3.10.3/src/test/groovy/RedissonClientTest.groovy index 2a120850a1d..89f0d1c2011 100644 --- a/dd-java-agent/instrumentation/redisson/redisson-3.10.3/src/test/groovy/RedissonClientTest.groovy +++ b/dd-java-agent/instrumentation/redisson/redisson-3.10.3/src/test/groovy/RedissonClientTest.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import com.redis.testcontainers.RedisContainer import com.redis.testcontainers.RedisServer import datadog.trace.agent.test.asserts.TraceAssert @@ -13,6 +16,9 @@ import spock.lang.Shared import static datadog.trace.api.config.TraceInstrumentationConfig.DB_CLIENT_HOST_SPLIT_BY_INSTANCE +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class RedissonClientTest extends VersionedNamingTestBase { @Shared diff --git a/dd-java-agent/instrumentation/restlet-2.2/src/test/groovy/RestletTestBase.groovy b/dd-java-agent/instrumentation/restlet-2.2/src/test/groovy/RestletTestBase.groovy index 7392b389023..4fa3a1e5153 100644 --- a/dd-java-agent/instrumentation/restlet-2.2/src/test/groovy/RestletTestBase.groovy +++ b/dd-java-agent/instrumentation/restlet-2.2/src/test/groovy/RestletTestBase.groovy @@ -1,6 +1,7 @@ import datadog.trace.agent.test.asserts.TraceAssert import datadog.trace.agent.test.base.HttpServer import datadog.trace.agent.test.base.HttpServerTest +import datadog.environment.OperatingSystem import datadog.trace.api.DDSpanTypes import datadog.trace.bootstrap.instrumentation.api.Tags import datadog.trace.instrumentation.restlet.ResourceDecorator @@ -29,9 +30,21 @@ import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.QUERY_ import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.QUERY_PARAM import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.REDIRECT import static datadog.trace.agent.test.base.HttpServerTest.ServerEndpoint.SUCCESS +import static datadog.trace.test.util.PlatformTestUtils.normalizeLocalhostHostname +import static datadog.trace.test.util.PlatformTestUtils.normalizeLocalhostUrl abstract class RestletTestBase extends HttpServerTest { + @Override + String normalizeServerHostname(String value) { + normalizeLocalhostHostname(value) + } + + @Override + String normalizeServerUrl(String value) { + normalizeLocalhostUrl(value) + } + class RestletServer implements HttpServer { def port = 0 Component restletComponent @@ -113,6 +126,18 @@ abstract class RestletTestBase extends HttpServerTest { false } + @Override + boolean testEncodedPath() { + // Restlet decodes encoded paths before the instrumentation observes them on Windows. + !OperatingSystem.isWindows() + } + + @Override + boolean testEncodedQuery() { + // Restlet decodes encoded queries before the instrumentation observes them on Windows. + !OperatingSystem.isWindows() + } + @Override Serializable expectedServerSpanRoute(ServerEndpoint endpoint) { switch (endpoint) { @@ -129,7 +154,9 @@ abstract class RestletTestBase extends HttpServerTest { @Override Map expectedExtraServerTags(ServerEndpoint endpoint) { - return [ (Tags.PEER_HOSTNAME): "localhost" ] + return [(Tags.PEER_HOSTNAME): { + normalizeLocalhostHostname(it as String) == "localhost" + }] } String capitalize(String word) { diff --git a/dd-java-agent/instrumentation/scala/scala-2.10.7/src/test/groovy/datadog/trace/instrumentation/scala/SourceCallSiteTest.groovy b/dd-java-agent/instrumentation/scala/scala-2.10.7/src/test/groovy/datadog/trace/instrumentation/scala/SourceCallSiteTest.groovy index b3c76bd964b..ea4ad0eed84 100644 --- a/dd-java-agent/instrumentation/scala/scala-2.10.7/src/test/groovy/datadog/trace/instrumentation/scala/SourceCallSiteTest.groovy +++ b/dd-java-agent/instrumentation/scala/scala-2.10.7/src/test/groovy/datadog/trace/instrumentation/scala/SourceCallSiteTest.groovy @@ -1,11 +1,13 @@ package datadog.trace.instrumentation.scala +import datadog.environment.OperatingSystem import datadog.trace.agent.test.server.http.TestHttpServer import datadog.trace.api.iast.InstrumentationBridge import datadog.trace.api.iast.sink.PathTraversalModule import datadog.trace.api.iast.sink.SsrfModule import spock.lang.AutoCleanup +import spock.lang.IgnoreIf import spock.lang.Shared import static datadog.trace.agent.test.server.http.TestHttpServer.httpServer @@ -27,6 +29,9 @@ class SourceCallSiteTest extends AbstractIastScalaTest { return 'foo.bar.TestSourceSuite' } + @IgnoreIf(reason = "Uses POSIX /etc/passwd test inputs, which are not available on Windows", value = { + OperatingSystem.isWindows() + }) void 'test scala.io.Source.#method'() { setup: final module = Mock(PathTraversalModule) diff --git a/dd-java-agent/instrumentation/spark/spark-common/src/testFixtures/groovy/datadog/trace/instrumentation/spark/AbstractSparkStructuredStreamingTest.groovy b/dd-java-agent/instrumentation/spark/spark-common/src/testFixtures/groovy/datadog/trace/instrumentation/spark/AbstractSparkStructuredStreamingTest.groovy index 21698955136..d6556f6aa33 100644 --- a/dd-java-agent/instrumentation/spark/spark-common/src/testFixtures/groovy/datadog/trace/instrumentation/spark/AbstractSparkStructuredStreamingTest.groovy +++ b/dd-java-agent/instrumentation/spark/spark-common/src/testFixtures/groovy/datadog/trace/instrumentation/spark/AbstractSparkStructuredStreamingTest.groovy @@ -1,6 +1,7 @@ package datadog.trace.instrumentation.spark import datadog.environment.JavaVirtualMachine +import datadog.environment.OperatingSystem import datadog.trace.agent.test.InstrumentationSpecification import datadog.trace.api.DDTags import datadog.trace.api.DDTraceId @@ -16,8 +17,8 @@ import scala.collection.JavaConverters import scala.collection.immutable.Seq import spock.lang.IgnoreIf -@IgnoreIf(reason="https://issues.apache.org/jira/browse/HADOOP-18174", value = { - JavaVirtualMachine.isJ9() +@IgnoreIf(reason="Requires Hadoop winutils on Windows; J9 is affected by HADOOP-18174", inherited = true, value = { + OperatingSystem.isWindows() || JavaVirtualMachine.isJ9() }) class AbstractSparkStructuredStreamingTest extends InstrumentationSpecification { diff --git a/dd-java-agent/instrumentation/spark/spark-common/src/testFixtures/groovy/datadog/trace/instrumentation/spark/AbstractSparkTest.groovy b/dd-java-agent/instrumentation/spark/spark-common/src/testFixtures/groovy/datadog/trace/instrumentation/spark/AbstractSparkTest.groovy index fce5f87f658..a942788304c 100644 --- a/dd-java-agent/instrumentation/spark/spark-common/src/testFixtures/groovy/datadog/trace/instrumentation/spark/AbstractSparkTest.groovy +++ b/dd-java-agent/instrumentation/spark/spark-common/src/testFixtures/groovy/datadog/trace/instrumentation/spark/AbstractSparkTest.groovy @@ -1,6 +1,7 @@ package datadog.trace.instrumentation.spark import datadog.environment.JavaVirtualMachine +import datadog.environment.OperatingSystem import datadog.trace.agent.test.InstrumentationSpecification import datadog.trace.api.DDSpanId import datadog.trace.api.DDTraceId @@ -21,8 +22,8 @@ import org.apache.spark.sql.SparkSession import org.apache.spark.sql.types.StructType import spock.lang.IgnoreIf -@IgnoreIf(reason="https://issues.apache.org/jira/browse/HADOOP-18174", value = { - JavaVirtualMachine.isJ9() +@IgnoreIf(reason="Requires Hadoop winutils on Windows; J9 is affected by HADOOP-18174", inherited = true, value = { + OperatingSystem.isWindows() || JavaVirtualMachine.isJ9() }) abstract class AbstractSparkTest extends InstrumentationSpecification { @Override diff --git a/dd-java-agent/instrumentation/spring/spring-rabbit-1.5/src/test/groovy/SpringAmqpTest.groovy b/dd-java-agent/instrumentation/spring/spring-rabbit-1.5/src/test/groovy/SpringAmqpTest.groovy index c76dda008c3..4c15a4ea8e1 100644 --- a/dd-java-agent/instrumentation/spring/spring-rabbit-1.5/src/test/groovy/SpringAmqpTest.groovy +++ b/dd-java-agent/instrumentation/spring/spring-rabbit-1.5/src/test/groovy/SpringAmqpTest.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import datadog.trace.agent.test.InstrumentationSpecification import datadog.trace.agent.test.utils.PortUtils import org.testcontainers.containers.RabbitMQContainer @@ -10,6 +13,9 @@ import java.util.concurrent.TimeUnit import static datadog.trace.agent.test.utils.TraceUtils.runUnderTrace +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) class SpringAmqpTest extends InstrumentationSpecification { @Shared diff --git a/dd-java-agent/instrumentation/spymemcached-2.10/src/test/groovy/datadog/trace/instrumentation/spymemcached/SpymemcachedTest.groovy b/dd-java-agent/instrumentation/spymemcached-2.10/src/test/groovy/datadog/trace/instrumentation/spymemcached/SpymemcachedTest.groovy index f8d8d3d593d..5077523c526 100644 --- a/dd-java-agent/instrumentation/spymemcached-2.10/src/test/groovy/datadog/trace/instrumentation/spymemcached/SpymemcachedTest.groovy +++ b/dd-java-agent/instrumentation/spymemcached-2.10/src/test/groovy/datadog/trace/instrumentation/spymemcached/SpymemcachedTest.groovy @@ -1,5 +1,8 @@ package datadog.trace.instrumentation.spymemcached +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import com.google.common.util.concurrent.MoreExecutors import datadog.trace.agent.test.asserts.TraceAssert import datadog.trace.agent.test.naming.VersionedNamingTestBase @@ -29,6 +32,9 @@ import static datadog.trace.instrumentation.spymemcached.MemcacheClientDecorator import static datadog.trace.instrumentation.spymemcached.MemcacheClientDecorator.COMPONENT_NAME import static net.spy.memcached.ConnectionFactoryBuilder.Protocol.BINARY +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class SpymemcachedTest extends VersionedNamingTestBase { @Shared diff --git a/dd-java-agent/instrumentation/vertx/vertx-mysql-client/vertx-mysql-client-3.9/src/test/groovy/VertxSqlClientForkedTest.groovy b/dd-java-agent/instrumentation/vertx/vertx-mysql-client/vertx-mysql-client-3.9/src/test/groovy/VertxSqlClientForkedTest.groovy index 19ce31ce76b..3d9e8d03b0d 100644 --- a/dd-java-agent/instrumentation/vertx/vertx-mysql-client/vertx-mysql-client-3.9/src/test/groovy/VertxSqlClientForkedTest.groovy +++ b/dd-java-agent/instrumentation/vertx/vertx-mysql-client/vertx-mysql-client-3.9/src/test/groovy/VertxSqlClientForkedTest.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import TestDatabases.TestDBInfo import datadog.trace.agent.test.InstrumentationSpecification import datadog.trace.agent.test.asserts.TraceAssert @@ -27,6 +30,9 @@ import java.util.concurrent.atomic.AtomicReference import static datadog.trace.agent.test.utils.TraceUtils.basicSpan import static datadog.trace.agent.test.utils.TraceUtils.runUnderTrace +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) class VertxSqlClientForkedTest extends InstrumentationSpecification { @AutoCleanup @Shared diff --git a/dd-java-agent/instrumentation/vertx/vertx-mysql-client/vertx-mysql-client-4.0/src/test/groovy/VertxSqlClientForkedTest.groovy b/dd-java-agent/instrumentation/vertx/vertx-mysql-client/vertx-mysql-client-4.0/src/test/groovy/VertxSqlClientForkedTest.groovy index 3e43b95bed0..ff5bc3972a6 100644 --- a/dd-java-agent/instrumentation/vertx/vertx-mysql-client/vertx-mysql-client-4.0/src/test/groovy/VertxSqlClientForkedTest.groovy +++ b/dd-java-agent/instrumentation/vertx/vertx-mysql-client/vertx-mysql-client-4.0/src/test/groovy/VertxSqlClientForkedTest.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import TestDatabases.TestDBInfo import datadog.trace.agent.test.InstrumentationSpecification import datadog.trace.agent.test.asserts.TraceAssert @@ -29,6 +32,9 @@ import java.util.concurrent.atomic.AtomicReference import static datadog.trace.agent.test.utils.TraceUtils.basicSpan import static datadog.trace.agent.test.utils.TraceUtils.runUnderTrace +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) class VertxSqlClientForkedTest extends InstrumentationSpecification { @AutoCleanup @Shared diff --git a/dd-java-agent/instrumentation/vertx/vertx-pg-client/vertx-pg-client-4.0/src/test/groovy/VertxPostgresSqlClientForkedTest.groovy b/dd-java-agent/instrumentation/vertx/vertx-pg-client/vertx-pg-client-4.0/src/test/groovy/VertxPostgresSqlClientForkedTest.groovy index cb6e0b965f0..5fa46ff3c11 100644 --- a/dd-java-agent/instrumentation/vertx/vertx-pg-client/vertx-pg-client-4.0/src/test/groovy/VertxPostgresSqlClientForkedTest.groovy +++ b/dd-java-agent/instrumentation/vertx/vertx-pg-client/vertx-pg-client-4.0/src/test/groovy/VertxPostgresSqlClientForkedTest.groovy @@ -1,4 +1,5 @@ import TestDatabases.TestDBInfo +import datadog.environment.OperatingSystem import datadog.environment.JavaVirtualMachine import datadog.trace.agent.test.InstrumentationSpecification import datadog.trace.agent.test.asserts.TraceAssert @@ -20,8 +21,8 @@ import java.util.concurrent.TimeUnit import static datadog.trace.agent.test.utils.TraceUtils.basicSpan import static datadog.trace.agent.test.utils.TraceUtils.runUnderTrace -@IgnoreIf(reason = "A change in Locale.ROOT that was introduced in JDK 22 is not fixed until vertx-pg-client v4.5.1: https://github.com/eclipse-vertx/vertx-sql-client/pull/1394", value = { - JavaVirtualMachine.isJavaVersionAtLeast(22) +@IgnoreIf(reason = "Requires Linux Testcontainers on Windows; JDK 22 also requires vertx-pg-client 4.5.1", value = { + OperatingSystem.isWindows() || JavaVirtualMachine.isJavaVersionAtLeast(22) }) class VertxPostgresSqlClientForkedTest extends InstrumentationSpecification { @AutoCleanup diff --git a/dd-java-agent/instrumentation/vertx/vertx-redis-client/vertx-redis-client-3.9/src/test/groovy/VertxRedisTestBase.groovy b/dd-java-agent/instrumentation/vertx/vertx-redis-client/vertx-redis-client-3.9/src/test/groovy/VertxRedisTestBase.groovy index 8c26b18680a..53750910b49 100644 --- a/dd-java-agent/instrumentation/vertx/vertx-redis-client/vertx-redis-client-3.9/src/test/groovy/VertxRedisTestBase.groovy +++ b/dd-java-agent/instrumentation/vertx/vertx-redis-client/vertx-redis-client-3.9/src/test/groovy/VertxRedisTestBase.groovy @@ -1,3 +1,6 @@ +import datadog.environment.OperatingSystem +import spock.lang.IgnoreIf + import com.redis.testcontainers.RedisContainer import datadog.trace.agent.test.asserts.ListWriterAssert import datadog.trace.agent.test.asserts.TraceAssert @@ -29,6 +32,9 @@ import static datadog.trace.agent.test.utils.TraceUtils.basicSpan import static datadog.trace.agent.test.utils.TraceUtils.runUnderTrace import static datadog.trace.bootstrap.instrumentation.api.AgentTracer.activeSpan +@IgnoreIf(reason = "Requires a Docker environment capable of running Linux Testcontainers", inherited = true, value = { + OperatingSystem.isWindows() +}) abstract class VertxRedisTestBase extends VersionedNamingTestBase { @Shared diff --git a/dd-java-agent/src/test/groovy/datadog/trace/agent/InitializationTelemetryTest.groovy b/dd-java-agent/src/test/groovy/datadog/trace/agent/InitializationTelemetryTest.groovy index 6c957a4a3fd..f5cfc99cd66 100644 --- a/dd-java-agent/src/test/groovy/datadog/trace/agent/InitializationTelemetryTest.groovy +++ b/dd-java-agent/src/test/groovy/datadog/trace/agent/InitializationTelemetryTest.groovy @@ -1,6 +1,7 @@ package datadog.trace.agent import datadog.environment.JavaVirtualMachine +import datadog.environment.OperatingSystem import datadog.test.SimpleAgentMock import jvmbootstraptest.InitializationTelemetryCheck import spock.lang.IgnoreIf @@ -8,6 +9,9 @@ import spock.lang.Specification import spock.lang.Timeout @Timeout(30) +@IgnoreIf(reason = "Uses POSIX file permissions and a Bash telemetry forwarder", value = { + OperatingSystem.isWindows() +}) class InitializationTelemetryTest extends Specification { @IgnoreIf(reason = "SecurityManager is permanently disabled as of JDK 24", value = { JavaVirtualMachine.isJavaVersionAtLeast(24) diff --git a/dd-java-agent/src/test/groovy/datadog/trace/bootstrap/AgentPreCheckTest.groovy b/dd-java-agent/src/test/groovy/datadog/trace/bootstrap/AgentPreCheckTest.groovy index fef98032091..c0b34535c2f 100644 --- a/dd-java-agent/src/test/groovy/datadog/trace/bootstrap/AgentPreCheckTest.groovy +++ b/dd-java-agent/src/test/groovy/datadog/trace/bootstrap/AgentPreCheckTest.groovy @@ -3,10 +3,13 @@ package datadog.trace.bootstrap import spock.lang.Specification import spock.util.concurrent.PollingConditions +import java.nio.file.FileSystems import java.nio.file.Files import java.nio.file.Path import java.nio.file.attribute.PosixFilePermissions +import static org.junit.jupiter.api.Assumptions.assumeTrue + class AgentPreCheckTest extends Specification { def 'parse java.version of #version as #expected'() { when: @@ -63,7 +66,7 @@ class AgentPreCheckTest extends Specification { when: boolean compatible = AgentPreCheck.compatible(javaVersion, "/Library/$javaVersion", logStream) String log = output.toString() - def logLines = log.isEmpty() ? [] : Arrays.asList(log.split('\n')) + def logLines = log.readLines() then: compatible == expectedCompatible @@ -92,6 +95,7 @@ class AgentPreCheckTest extends Specification { def 'send hardcoded bootstrap telemetry for unsupported java'() { setup: + assumeTrue(FileSystems.default.supportedFileAttributeViews().contains('posix')) Path path = Files.createTempFile('test-forwarder', '.sh', PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString('rwxr--r--'))) File forwarderFile = path.toFile() forwarderFile.deleteOnExit() diff --git a/dd-trace-core/src/test/java/datadog/trace/TracerConnectionReliabilityTest.java b/dd-trace-core/src/test/java/datadog/trace/TracerConnectionReliabilityTest.java index cfda2f730c7..c9a44cbc25b 100644 --- a/dd-trace-core/src/test/java/datadog/trace/TracerConnectionReliabilityTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/TracerConnectionReliabilityTest.java @@ -26,10 +26,15 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; import org.testcontainers.containers.FixedHostPortGenericContainer; import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.Wait; +@DisabledOnOs( + value = OS.WINDOWS, + disabledReason = "Requires a Docker environment capable of running Linux containers") public class TracerConnectionReliabilityTest extends DDJavaSpecification { static final int FEATURES_DISCOVERY_MIN_DELAY = 10; diff --git a/dd-trace-core/src/test/java/datadog/trace/core/util/StackTracesTest.java b/dd-trace-core/src/test/java/datadog/trace/core/util/StackTracesTest.java index d7d9b045735..c114377dde7 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/util/StackTracesTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/util/StackTracesTest.java @@ -1,10 +1,12 @@ package datadog.trace.core.util; +import static datadog.trace.test.util.PlatformTestUtils.normalizeLineEndings; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.params.provider.Arguments.arguments; +import datadog.environment.OperatingSystem; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -111,7 +113,23 @@ void getStackTraceFallsBackToClassNameWhenGetMessageAlsoThrows() { @ParameterizedTest(name = "truncation limit {0}") @MethodSource("testTruncateArguments") void testTruncate(int limit, String expected) { - assertEquals(expected, StackTraces.truncate(TRACE, limit)); + assertEquals( + normalizeTruncatedTrace(expected), + normalizeTruncatedTrace(StackTraces.truncate(TRACE, limit))); + } + + private static String normalizeTruncatedTrace(String trace) { + String normalizedTrace = normalizeLineEndings(trace); + if (!OperatingSystem.isWindows()) { + return normalizedTrace; + } + return normalizedTrace + // Native line endings change the exact character split around a centre cut. The content on + // the adjacent partial lines is intentionally unspecified; the marker and all complete + // lines remain exact. + .replaceAll( + "(?m)^.*\\n(\\t\\.\\.\\. trace centre-cut to \\d+ chars \\.\\.\\.\\n).*$", + "\n$1"); } static Stream testTruncateArguments() { diff --git a/dd-trace-core/src/test/java/datadog/trace/core/util/SystemAccessTest.java b/dd-trace-core/src/test/java/datadog/trace/core/util/SystemAccessTest.java index 0f549c07cea..f764e37ab16 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/util/SystemAccessTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/util/SystemAccessTest.java @@ -56,7 +56,7 @@ void testCpuTime( if (hasCpuTime) { assertNotEquals(Long.MIN_VALUE, threadCpuTime1); assertNotEquals(Long.MIN_VALUE, threadCpuTime2); - assertTrue(threadCpuTime2 > threadCpuTime1); + assertTrue(threadCpuTime2 >= threadCpuTime1); } else { assertEquals(Long.MIN_VALUE, threadCpuTime1); assertEquals(Long.MIN_VALUE, threadCpuTime2); diff --git a/internal-api/src/main/java/datadog/trace/api/git/GitUtils.java b/internal-api/src/main/java/datadog/trace/api/git/GitUtils.java index 892d4986bde..0462eb202fa 100644 --- a/internal-api/src/main/java/datadog/trace/api/git/GitUtils.java +++ b/internal-api/src/main/java/datadog/trace/api/git/GitUtils.java @@ -38,6 +38,8 @@ public class GitUtils { + "|/$" // ends with slash ); private static final Pattern PATH_PATTERN = Pattern.compile("^[a-zA-Z0-9_./-]+$"); + private static final Pattern WINDOWS_PATH_PATTERN = + Pattern.compile("^(?:[a-zA-Z]:[\\\\/]|\\\\\\\\)[a-zA-Z0-9_ .\\\\/-]*$"); private static final Pattern SHELL_METACHAR_PATTERN = Pattern.compile(".*[`$&|;<>\n\r#].*"); private static final int SHORT_SHA_LENGTH = 7; @@ -290,7 +292,7 @@ public static boolean isValidRef(@Nullable String ref) { /** Checks if the provided string is a valid system path for Git operations */ public static boolean isValidPath(@Nonnull String path) { - if (!PATH_PATTERN.matcher(path).matches()) { + if (!PATH_PATTERN.matcher(path).matches() && !WINDOWS_PATH_PATTERN.matcher(path).matches()) { return false; } // Reject path traversal sequences diff --git a/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy index cc271a08fc0..d7b17976d9d 100644 --- a/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy +++ b/internal-api/src/test/groovy/datadog/trace/api/ConfigTest.groovy @@ -172,6 +172,7 @@ import datadog.trace.bootstrap.config.provider.ConfigConverter import datadog.trace.bootstrap.config.provider.ConfigProvider import datadog.trace.test.util.DDSpecification import datadog.trace.util.throwable.FatalAgentMisconfigurationError +import java.nio.file.Paths class ConfigTest extends DDSpecification { private static final String PREFIX = "dd." @@ -1715,7 +1716,7 @@ class ConfigTest extends DDSpecification { where: // spotless:off path | expectedKey - getClass().getClassLoader().getResource("apikey").getFile() | "test-api-key" + resourcePath("apikey") | "test-api-key" "/path/that/doesnt/exist" | "default-api-key" // spotless:on } @@ -1745,7 +1746,7 @@ class ConfigTest extends DDSpecification { where: // spotless:off path | expectedKey - getClass().getClassLoader().getResource("apikey.old").getFile() | "test-api-key-old" + resourcePath("apikey.old") | "test-api-key-old" "/path/that/doesnt/exist" | "default-api-key" // spotless:on } @@ -1774,14 +1775,14 @@ class ConfigTest extends DDSpecification { where: path | expectedKey - getClass().getClassLoader().getResource("apikey.very-old").getFile() | "test-api-key-very-old" + resourcePath("apikey.very-old") | "test-api-key-very-old" "/path/that/doesnt/exist" | "default-api-key" } def "verify api key loaded from new option when both new and old are set"() { setup: - System.setProperty(PREFIX + API_KEY_FILE, getClass().getClassLoader().getResource("apikey").getFile()) - System.setProperty(PREFIX + PROFILING_API_KEY_FILE_OLD, getClass().getClassLoader().getResource("apikey.old").getFile()) + System.setProperty(PREFIX + API_KEY_FILE, resourcePath("apikey")) + System.setProperty(PREFIX + PROFILING_API_KEY_FILE_OLD, resourcePath("apikey.old")) when: def config = new Config() @@ -1792,8 +1793,8 @@ class ConfigTest extends DDSpecification { def "verify api key loaded from new option when both old and very old are set"() { setup: - System.setProperty(PREFIX + PROFILING_API_KEY_FILE_OLD, getClass().getClassLoader().getResource("apikey.old").getFile()) - System.setProperty(PREFIX + PROFILING_API_KEY_FILE_VERY_OLD, getClass().getClassLoader().getResource("apikey.very-old").getFile()) + System.setProperty(PREFIX + PROFILING_API_KEY_FILE_OLD, resourcePath("apikey.old")) + System.setProperty(PREFIX + PROFILING_API_KEY_FILE_VERY_OLD, resourcePath("apikey.very-old")) when: def config = new Config() @@ -3606,4 +3607,8 @@ class ConfigTest extends DDSpecification { config.featureFlaggingConfigurationSourcePollIntervalSeconds == DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_POLL_INTERVAL_SECONDS config.featureFlaggingConfigurationSourceRequestTimeoutSeconds == DEFAULT_FEATURE_FLAGGING_CONFIGURATION_SOURCE_REQUEST_TIMEOUT_SECONDS } + + private String resourcePath(String name) { + return Paths.get(getClass().getClassLoader().getResource(name).toURI()).toString() + } } diff --git a/internal-api/src/test/groovy/datadog/trace/api/git/GitUtilsTest.groovy b/internal-api/src/test/groovy/datadog/trace/api/git/GitUtilsTest.groovy index 657b367540b..f680e62d60a 100644 --- a/internal-api/src/test/groovy/datadog/trace/api/git/GitUtilsTest.groovy +++ b/internal-api/src/test/groovy/datadog/trace/api/git/GitUtilsTest.groovy @@ -155,6 +155,8 @@ class GitUtilsTest extends Specification { "multiple/levels/of/nesting" | true "/absolute/path" | true // absolute paths allowed "/home/user/workspace" | true // typical CI workspace + "C:\\Users\\build\\workspace" | true // Windows absolute path + "C:\\Program Files\\repo" | true // Windows path with spaces "../parent/path" | false // path traversal at start "path/../other" | false // path traversal in middle "path/to/.." | false // path traversal at end diff --git a/internal-api/src/test/groovy/datadog/trace/util/ProcessSupervisorTest.groovy b/internal-api/src/test/groovy/datadog/trace/util/ProcessSupervisorTest.groovy index eb8af334502..dce93754c55 100644 --- a/internal-api/src/test/groovy/datadog/trace/util/ProcessSupervisorTest.groovy +++ b/internal-api/src/test/groovy/datadog/trace/util/ProcessSupervisorTest.groovy @@ -1,14 +1,15 @@ package datadog.trace.util import datadog.trace.test.util.DDSpecification +import datadog.trace.test.util.PortableCommand import spock.util.concurrent.PollingConditions // This test looks at the private "currentProcess" variable because the alternative // would be calling "ps -e" repeatedly class ProcessSupervisorTest extends DDSpecification { ProcessBuilder createProcessBuilder() { - // Creates a process that never returns - return new ProcessBuilder("tail", "-f", "/dev/null") + // Creates a process that never returns on its own + return new ProcessBuilder(PortableCommand.runForever()) } def "Process killed when supervisor closed"() { diff --git a/internal-api/src/test/java/datadog/trace/util/TempLocationManagerTest.java b/internal-api/src/test/java/datadog/trace/util/TempLocationManagerTest.java index af22b5fe804..b509f8c1c6f 100644 --- a/internal-api/src/test/java/datadog/trace/util/TempLocationManagerTest.java +++ b/internal-api/src/test/java/datadog/trace/util/TempLocationManagerTest.java @@ -13,6 +13,7 @@ import datadog.trace.api.time.TimeSource; import datadog.trace.bootstrap.config.provider.ConfigProvider; import java.io.IOException; +import java.nio.file.FileSystems; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; @@ -29,6 +30,8 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -52,10 +55,7 @@ void testDefault(String subPath) throws Exception { @ParameterizedTest @ValueSource(strings = {"", "test1"}) void testFromConfig(String subPath) throws Exception { - Path myDir = - Files.createTempDirectory( - "ddprof-test-", - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + Path myDir = createSecureTempDirectory(); myDir.toFile().deleteOnExit(); Properties props = new Properties(); props.put(ProfilingConfig.PROFILING_TEMP_DIR, myDir.toString()); @@ -77,6 +77,7 @@ void testFromConfigInvalid() { } @Test + @DisabledOnOs(value = OS.WINDOWS, disabledReason = "Requires POSIX file permissions") void testFromConfigNotWritable() throws Exception { Path myDir = Files.createTempDirectory( @@ -92,10 +93,7 @@ void testFromConfigNotWritable() throws Exception { @ParameterizedTest @ValueSource(strings = {"", "test1"}) void testCleanup(String subPath) throws Exception { - Path myDir = - Files.createTempDirectory( - "ddprof-test-", - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + Path myDir = createSecureTempDirectory(); myDir.toFile().deleteOnExit(); TempLocationManager tempLocationManager = instance(myDir, false, TempLocationManager.CleanupHook.EMPTY); @@ -131,17 +129,12 @@ void testConcurrentCleanup(String section) throws Exception { * 2. Main thread deletes file, signals via proceedSignal latch * 3. Cleanup thread continues and completes */ - Path baseDir = - Files.createTempDirectory( - "ddprof-test-", - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + Path baseDir = createSecureTempDirectory(); baseDir.toFile().deleteOnExit(); Path fakeTempDir = baseDir.resolve(TempLocationManager.getBaseTempDirName() + "/pid_fake/scratch"); - Files.createDirectories( - fakeTempDir, - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + createSecureDirectories(fakeTempDir); Path fakeTempFile = fakeTempDir.resolve("libxxx.so"); Files.createFile(fakeTempFile); @@ -286,10 +279,7 @@ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs, boolean t return TempLocationManager.CleanupHook.super.visitFile(file, attrs, timeout); } }; - Path baseDir = - Files.createTempDirectory( - "ddprof-test-", - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + Path baseDir = createSecureTempDirectory(); baseDir.toFile().deleteOnExit(); TempLocationManager instance = instance(baseDir, false, delayer, timeSource); Path mytempdir = instance.getTempDir(); @@ -314,6 +304,27 @@ private static Stream timeoutTestArguments() { return argumentsList.stream(); } + private static Path createSecureTempDirectory() throws IOException { + return supportsPosixPermissions() + ? Files.createTempDirectory( + "ddprof-test-", + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))) + : Files.createTempDirectory("ddprof-test-"); + } + + private static void createSecureDirectories(Path path) throws IOException { + if (supportsPosixPermissions()) { + Files.createDirectories( + path, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + } else { + Files.createDirectories(path); + } + } + + private static boolean supportsPosixPermissions() { + return FileSystems.getDefault().supportedFileAttributeViews().contains("posix"); + } + private TempLocationManager instance( Path baseDir, boolean withStartupCleanup, TempLocationManager.CleanupHook cleanupHook) { return instance(baseDir, withStartupCleanup, cleanupHook, SystemTimeSource.INSTANCE); diff --git a/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/DefaultConfigurationPollerSpecification.java b/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/DefaultConfigurationPollerSpecification.java index 51922d9cbfa..3ececb5498c 100644 --- a/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/DefaultConfigurationPollerSpecification.java +++ b/remote-config/remote-config-core/src/test/java/datadog/remoteconfig/DefaultConfigurationPollerSpecification.java @@ -2,6 +2,7 @@ import static datadog.remoteconfig.tuf.RemoteConfigRequest.ClientInfo.ClientState.ConfigState.APPLY_STATE_ERROR; import static datadog.trace.test.junit.utils.config.WithConfigExtension.injectSysConfig; +import static datadog.trace.test.util.PlatformTestUtils.normalizeLineEndings; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; @@ -903,7 +904,7 @@ void reportableErrors(String scenario, String bodyStr, String errorMsg) throws I Map state = clientState(parseBody()); assertTrue(asList(state.get("config_states")).isEmpty()); assertEquals(Boolean.TRUE, state.get("has_error")); - assertEquals(errorMsg, state.get("error")); + assertEquals(errorMsg, normalizeLineEndings((String) state.get("error"))); } @Test diff --git a/telemetry/src/main/java/datadog/telemetry/HostInfo.java b/telemetry/src/main/java/datadog/telemetry/HostInfo.java index 1d950ece8e2..ceae0c30cd0 100644 --- a/telemetry/src/main/java/datadog/telemetry/HostInfo.java +++ b/telemetry/src/main/java/datadog/telemetry/HostInfo.java @@ -40,6 +40,8 @@ public static String getOsName() { if (OperatingSystem.isMacOs()) { // os.name == Mac OS X, while uanme -s == Darwin. We'll hardcode it to Darwin. osName = "Darwin"; + } else if (OperatingSystem.isWindows()) { + osName = "Windows"; } else { osName = SystemProperties.get("os.name"); } diff --git a/telemetry/src/main/java/datadog/telemetry/dependency/DependencyResolver.java b/telemetry/src/main/java/datadog/telemetry/dependency/DependencyResolver.java index 91ec8c52162..f22772b231c 100644 --- a/telemetry/src/main/java/datadog/telemetry/dependency/DependencyResolver.java +++ b/telemetry/src/main/java/datadog/telemetry/dependency/DependencyResolver.java @@ -68,14 +68,13 @@ private static JarReader.Extracted resolveNestedJar(final URI uri) throws IOExce if (path.startsWith("file:")) { // Old style nested dependencies, as seen in Spring Boot 2 and others. // These look like jar:file:/path/to.jar!/path/to/nested.jar!/ - path = path.substring("file:".length()); final int sepIdx = path.indexOf("!/"); if (sepIdx == -1) { // JBoss may use the "jar:file" format to reference jar files instead of nested jars. // These look like: jar:file:/path/to.jar!/ - return JarReader.readJarFile(path); + return JarReader.readJarFile(new File(URI.create(path)).getPath()); } - final String outerPath = path.substring(0, sepIdx); + final String outerPath = new File(URI.create(path.substring(0, sepIdx))).getPath(); final String innerPath = path.substring(sepIdx + 2); return JarReader.readNestedJarFile(outerPath, innerPath); } else if (path.startsWith("nested:")) { diff --git a/telemetry/src/test/groovy/datadog/telemetry/dependency/DependencyResolverSpecification.groovy b/telemetry/src/test/groovy/datadog/telemetry/dependency/DependencyResolverSpecification.groovy index 6e8096bc57a..dd5a41a2a08 100644 --- a/telemetry/src/test/groovy/datadog/telemetry/dependency/DependencyResolverSpecification.groovy +++ b/telemetry/src/test/groovy/datadog/telemetry/dependency/DependencyResolverSpecification.groovy @@ -243,7 +243,7 @@ class DependencyResolverSpecification extends DepSpecification { out.close() when: - URI uri = new URI("jar:file:" + file.getAbsolutePath() + "!/BOOT-INF/lib/lib-1.0.jar!/") + URI uri = new URI("jar:" + file.toURI() + "!/BOOT-INF/lib/lib-1.0.jar!/") List deps = DependencyResolver.resolve(uri) then: @@ -328,13 +328,13 @@ class DependencyResolverSpecification extends DepSpecification { out.close() when: - def deps = DependencyResolver.resolve(new URI('jar:file:' + file.getAbsolutePath() + "!/classes!/")) + def deps = DependencyResolver.resolve(new URI('jar:' + file.toURI() + "!/classes!/")) then: deps.isEmpty() when: 'resolve without catching exceptions' - deps = DependencyResolver.internalResolve(new URI('jar:file:' + file.getAbsolutePath() + "!/classes!/")) + deps = DependencyResolver.internalResolve(new URI('jar:' + file.toURI() + "!/classes!/")) then: 'it does not throw' deps.isEmpty() diff --git a/telemetry/src/test/java/datadog/telemetry/HostInfoTest.java b/telemetry/src/test/java/datadog/telemetry/HostInfoTest.java index d4266fa7bfb..24d948de5c6 100644 --- a/telemetry/src/test/java/datadog/telemetry/HostInfoTest.java +++ b/telemetry/src/test/java/datadog/telemetry/HostInfoTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.junit.jupiter.api.Assumptions.assumeTrue; import datadog.environment.OperatingSystem; @@ -42,6 +43,7 @@ void getOsVersion() { @Test void compareToUname() throws IOException, InterruptedException { + assumeFalse(OperatingSystem.isWindows()); assumeTrue(exitCode("uname", "-a") == 0); assertEquals(runCommand("uname", "-n"), HostInfo.getHostname()); diff --git a/utils/container-utils/src/test/java/datadog/common/container/ContainerInfoTest.java b/utils/container-utils/src/test/java/datadog/common/container/ContainerInfoTest.java index cb6fe8bf0d9..0836543a1d4 100644 --- a/utils/container-utils/src/test/java/datadog/common/container/ContainerInfoTest.java +++ b/utils/container-utils/src/test/java/datadog/common/container/ContainerInfoTest.java @@ -19,6 +19,8 @@ import java.util.List; import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -251,6 +253,7 @@ void containerInfoToleratesMissingContainerIdAndPodIdInProcfile() throws Excepti } @Test + @DisabledOnOs(value = OS.WINDOWS, disabledReason = "Requires Unix inode support and ls") void getInoPathShouldReturnSameValueAsLsIdPath() throws Exception { File f = File.createTempFile("container-info-test-", "-inode-file"); f.deleteOnExit(); @@ -295,6 +298,7 @@ void readEntityIDReturnNullIfContainerIdIsNotDefinedAndIsHostCgroupNamespace(Str "[''] | true ", "[memory] | false " }) + @DisabledOnOs(value = OS.WINDOWS, disabledReason = "Requires Unix inode support and ls") void readEntityIDReturnIdInoForEmptyController( List controllers, boolean hasEntityId) throws Exception { File mountPath = createTempDir(); @@ -319,6 +323,7 @@ void readEntityIDReturnIdInoForEmptyController( "[memory] | true ", "[''] | false " }) + @DisabledOnOs(value = OS.WINDOWS, disabledReason = "Requires Unix inode support and ls") void readEntityIDReturnIdInoForMemoryController( List controllers, boolean hasEntityId) throws Exception { File mountPath = createTempDir(); @@ -341,6 +346,7 @@ void readEntityIDReturnIdInoForMemoryController( // spotless:on @Test + @DisabledOnOs(value = OS.WINDOWS, disabledReason = "Requires Unix inode support and ls") void readEntityIDReturnIdInoForParentWhenPathIsSlash() throws Exception { File mountPath = createTempDir(); File memoryController = Files.createDirectory(mountPath.toPath().resolve("memory")).toFile(); diff --git a/utils/socket-utils/src/test/java/datadog/common/socket/TunnelingJdkSocketTest.java b/utils/socket-utils/src/test/java/datadog/common/socket/TunnelingJdkSocketTest.java index 1c7c7ec7a19..67424053c93 100644 --- a/utils/socket-utils/src/test/java/datadog/common/socket/TunnelingJdkSocketTest.java +++ b/utils/socket-utils/src/test/java/datadog/common/socket/TunnelingJdkSocketTest.java @@ -23,7 +23,9 @@ import java.time.Duration; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; import org.junit.jupiter.api.condition.EnabledForJreRange; +import org.junit.jupiter.api.condition.OS; public class TunnelingJdkSocketTest { @@ -197,6 +199,7 @@ public void testBufferSizes() throws Exception { @Test @EnabledForJreRange(min = JAVA_16) + @DisabledOnOs(value = OS.WINDOWS, disabledReason = "Requires the Unix lsof command") public void testFileDescriptorLeak() throws Exception { long initialCount = getFileDescriptorCount(); diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/PlatformTestUtils.java b/utils/test-utils/src/main/java/datadog/trace/test/util/PlatformTestUtils.java new file mode 100644 index 00000000000..4ad8b442977 --- /dev/null +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/PlatformTestUtils.java @@ -0,0 +1,95 @@ +package datadog.trace.test.util; + +import datadog.environment.OperatingSystem; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** Normalizes values produced by the current platform for comparison with test fixtures. */ +public final class PlatformTestUtils { + private PlatformTestUtils() {} + + /** Converts Windows CRLF line endings to LF; returns the original value on other platforms. */ + public static String normalizeLineEndings(String value) { + return normalizeLineEndings(value, OperatingSystem.isWindows()); + } + + static String normalizeLineEndings(String value, boolean isWindows) { + return isWindows ? value.replace("\r\n", "\n") : value; + } + + /** Converts Windows path separators to slashes; returns the original value on other platforms. */ + public static String normalizePathSeparators(String value) { + return normalizePathSeparators(value, OperatingSystem.isWindows()); + } + + static String normalizePathSeparators(String value, boolean isWindows) { + return isWindows ? value.replace('\\', '/') : value; + } + + /** Normalizes a collection of paths; returns the original collection on non-Windows platforms. */ + public static Collection normalizePathSeparators(Collection values) { + return normalizePathSeparators(values, OperatingSystem.isWindows()); + } + + static Collection normalizePathSeparators(Collection values, boolean isWindows) { + if (!isWindows) { + return values; + } + List normalizedValues = new ArrayList<>(values.size()); + for (String value : values) { + normalizedValues.add(normalizePathSeparators(value, true)); + } + return normalizedValues; + } + + /** Removes the Windows executable suffix; returns the original value on other platforms. */ + public static String normalizeExecutableName(String value) { + return normalizeExecutableName(value, OperatingSystem.isWindows()); + } + + static String normalizeExecutableName(String value, boolean isWindows) { + if (isWindows + && value != null + && value.length() > 4 + && value.regionMatches(true, value.length() - 4, ".exe", 0, 4)) { + return value.substring(0, value.length() - 4); + } + return value; + } + + /** Treats an exact IPv4 loopback hostname as localhost on Windows for test comparisons. */ + public static String normalizeLocalhostHostname(String value) { + return normalizeLocalhostHostname(value, OperatingSystem.isWindows()); + } + + static String normalizeLocalhostHostname(String value, boolean isWindows) { + return isWindows && "127.0.0.1".equals(value) ? "localhost" : value; + } + + /** Treats an IPv4 loopback URL host as localhost on Windows, preserving the rest verbatim. */ + public static String normalizeLocalhostUrl(String value) { + return normalizeLocalhostUrl(value, OperatingSystem.isWindows()); + } + + static String normalizeLocalhostUrl(String value, boolean isWindows) { + if (!isWindows || value == null) { + return value; + } + try { + URI uri = URI.create(value); + if (!"127.0.0.1".equals(uri.getHost())) { + return value; + } + int authorityStart = value.indexOf("//") + 2; + int hostStart = + authorityStart + (uri.getRawUserInfo() == null ? 0 : uri.getRawUserInfo().length() + 1); + return value.substring(0, hostStart) + + "localhost" + + value.substring(hostStart + "127.0.0.1".length()); + } catch (IllegalArgumentException ignored) { + return value; + } + } +} diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommand.java b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommand.java new file mode 100644 index 00000000000..cc023a4d4d3 --- /dev/null +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommand.java @@ -0,0 +1,133 @@ +package datadog.trace.test.util; + +import datadog.environment.OperatingSystem; +import datadog.trace.api.internal.VisibleForTesting; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.CodeSource; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Builds command lines for a small set of command-line utilities so tests can spawn them without + * depending on the host operating system. + * + *

On POSIX platforms the native utilities are used directly. Windows has no usable equivalent + * for any of them — {@code echo} is a {@code cmd.exe} builtin rather than an executable, {@code + * type} cannot read standard input, and {@code timeout} refuses to run when standard input is + * redirected — so there the commands are emulated by {@link PortableCommandRunner} in a child JVM. + * + *

POSIX deliberately keeps the native utilities instead of emulating everywhere: forking a JVM + * is far more expensive than spawning a small native binary, in both startup time and memory, and + * effectively all CI runs on Linux — so the cheap path is the one that matters. It is also exactly + * what these tests spawned before this class existed, which leaves CI behavior unchanged. + * + *

Both paths are observably identical: {@code cat} copies bytes exactly, {@code sleep} takes a + * duration in seconds, and {@code echo} terminates its output with the platform line separator. + * Callers therefore never need to branch on the operating system. + * + *

Supported commands: + * + *

    + *
  • {@link #echo(String)} writes a value followed by the platform line separator. + *
  • {@link #cat()} copies standard input to standard output. + *
  • {@link #sleep(long)} waits for the requested number of seconds, then exits with 0. + *
  • {@link #runForever()} never exits on its own and must be destroyed by the caller. + *
+ */ +public final class PortableCommand { + private static final String MIN_HEAP = "-Xms8m"; + private static final String MAX_HEAP = "-Xmx16m"; + + /** Windows has no usable native equivalent for any of these commands. */ + private static final boolean EMULATED = OperatingSystem.isWindows(); + + private PortableCommand() {} + + public static String[] echo(String value) { + return echo(value, EMULATED); + } + + public static String[] cat() { + return cat(EMULATED); + } + + public static String[] sleep(long durationSec) { + return sleep(durationSec, EMULATED); + } + + public static String[] runForever() { + return runForever(EMULATED); + } + + @VisibleForTesting + static String[] echo(String value, boolean emulated) { + return emulated ? emulate("echo", value) : new String[] {"echo", value}; + } + + @VisibleForTesting + static String[] cat(boolean emulated) { + return emulated ? emulate("cat") : new String[] {"cat"}; + } + + @VisibleForTesting + static String[] sleep(long durationSec, boolean emulated) { + if (durationSec < 0) { + throw new IllegalArgumentException("Sleep duration must not be negative: " + durationSec); + } + // The native sleep takes seconds; the emulated runner takes milliseconds. + return emulated + ? emulate("sleep", Long.toString(durationSec * 1000)) + : new String[] {"sleep", Long.toString(durationSec)}; + } + + @VisibleForTesting + static String[] runForever(boolean emulated) { + return emulated + ? emulate("sleep", Long.toString(Long.MAX_VALUE)) + : new String[] {"tail", "-f", "/dev/null"}; + } + + private static String[] emulate(String... arguments) { + Path executable = javaExecutable(); + Path classpath = classpathEntry(); + + List command = new ArrayList<>(); + command.add(executable.toString()); + command.add(MIN_HEAP); + command.add(MAX_HEAP); + command.add("-cp"); + command.add(classpath.toString()); + command.add(PortableCommandRunner.class.getName()); + command.addAll(Arrays.asList(arguments)); + return command.toArray(new String[0]); + } + + private static Path javaExecutable() { + return javaExecutable(Paths.get(System.getProperty("java.home"))); + } + + @VisibleForTesting + static Path javaExecutable(Path javaHome) { + Path bin = javaHome.resolve("bin"); + for (String name : new String[] {"java", "java.exe"}) { + Path candidate = bin.resolve(name); + if (Files.isRegularFile(candidate)) { + return candidate; + } + } + throw new IllegalStateException("Could not find a Java executable under " + bin); + } + + private static Path classpathEntry() { + CodeSource source = PortableCommand.class.getProtectionDomain().getCodeSource(); + try { + return Paths.get(source.getLocation().toURI()); + } catch (Exception e) { + throw new IllegalStateException( + "Cannot determine the classpath of " + PortableCommand.class.getName(), e); + } + } +} diff --git a/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommandRunner.java b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommandRunner.java new file mode 100644 index 00000000000..f67b4b6963f --- /dev/null +++ b/utils/test-utils/src/main/java/datadog/trace/test/util/PortableCommandRunner.java @@ -0,0 +1,55 @@ +package datadog.trace.test.util; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import datadog.trace.api.internal.VisibleForTesting; +import de.thetaphi.forbiddenapis.SuppressForbidden; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Emulates the utilities described by {@link PortableCommand} inside a JVM, for platforms that have + * no usable native equivalent. Spawned as a child process; not meant to be called directly. + */ +public final class PortableCommandRunner { + private PortableCommandRunner() {} + + @SuppressForbidden + public static void main(String[] arguments) throws IOException, InterruptedException { + execute(arguments, System.in, System.out); + System.out.flush(); + } + + @VisibleForTesting + static void execute(String[] arguments, InputStream input, OutputStream output) + throws IOException, InterruptedException { + if (arguments.length == 0) { + throw new IllegalArgumentException("Missing command"); + } + switch (arguments[0]) { + case "echo": + output.write((argument(arguments) + System.lineSeparator()).getBytes(UTF_8)); + break; + case "cat": + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + break; + case "sleep": + Thread.sleep(Long.parseLong(argument(arguments))); + break; + default: + throw new IllegalArgumentException("Unknown command: " + arguments[0]); + } + } + + private static String argument(String[] arguments) { + if (arguments.length < 2) { + throw new IllegalArgumentException("Command '" + arguments[0] + "' requires an argument"); + } + return arguments[1]; + } +} diff --git a/utils/test-utils/src/test/java/datadog/trace/test/util/PlatformTestUtilsTest.java b/utils/test-utils/src/test/java/datadog/trace/test/util/PlatformTestUtilsTest.java new file mode 100644 index 00000000000..7a2b341353e --- /dev/null +++ b/utils/test-utils/src/test/java/datadog/trace/test/util/PlatformTestUtilsTest.java @@ -0,0 +1,96 @@ +package datadog.trace.test.util; + +import static datadog.trace.test.util.PlatformTestUtils.normalizeExecutableName; +import static datadog.trace.test.util.PlatformTestUtils.normalizeLineEndings; +import static datadog.trace.test.util.PlatformTestUtils.normalizeLocalhostHostname; +import static datadog.trace.test.util.PlatformTestUtils.normalizeLocalhostUrl; +import static datadog.trace.test.util.PlatformTestUtils.normalizePathSeparators; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import datadog.environment.OperatingSystem; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import org.junit.jupiter.api.Test; + +class PlatformTestUtilsTest { + @Test + void convertsWindowsLineEndingsOnlyOnWindows() { + String value = "first\r\nsecond"; + + assertEquals("first\nsecond", PlatformTestUtils.normalizeLineEndings(value, true)); + assertSame(value, PlatformTestUtils.normalizeLineEndings(value, false)); + assertEquals( + OperatingSystem.isWindows() ? "first\nsecond" : value, normalizeLineEndings(value)); + } + + @Test + void convertsWindowsPathSeparatorsOnlyOnWindows() { + String value = "directory\\file"; + + assertEquals("directory/file", PlatformTestUtils.normalizePathSeparators(value, true)); + assertSame(value, PlatformTestUtils.normalizePathSeparators(value, false)); + assertEquals( + OperatingSystem.isWindows() ? "directory/file" : value, normalizePathSeparators(value)); + } + + @Test + void convertsCollectionsWithoutMutatingTheInput() { + List values = Arrays.asList("directory\\file", "another\\file"); + Collection normalizedValues = PlatformTestUtils.normalizePathSeparators(values, true); + + assertNotSame(values, normalizedValues); + assertEquals(Arrays.asList("directory/file", "another/file"), normalizedValues); + assertEquals(Arrays.asList("directory\\file", "another\\file"), values); + assertSame(values, PlatformTestUtils.normalizePathSeparators(values, false)); + assertEquals( + OperatingSystem.isWindows() ? Arrays.asList("directory/file", "another/file") : values, + normalizePathSeparators(values)); + } + + @Test + void removesWindowsExecutableSuffixOnlyOnWindows() { + String windowsName = "java.exe"; + + assertEquals("java", PlatformTestUtils.normalizeExecutableName(windowsName, true)); + assertEquals("java", PlatformTestUtils.normalizeExecutableName("java.EXE", true)); + assertEquals("java", PlatformTestUtils.normalizeExecutableName("java", true)); + assertEquals("javac", PlatformTestUtils.normalizeExecutableName("javac", true)); + assertNull(PlatformTestUtils.normalizeExecutableName(null, true)); + assertSame(windowsName, PlatformTestUtils.normalizeExecutableName(windowsName, false)); + assertEquals( + OperatingSystem.isWindows() ? "java" : windowsName, normalizeExecutableName(windowsName)); + } + + @Test + void normalizesExactWindowsLocalhostHostnameOnlyOnWindows() { + String value = "127.0.0.1"; + + assertEquals("localhost", PlatformTestUtils.normalizeLocalhostHostname(value, true)); + assertEquals("127.0.0.10", PlatformTestUtils.normalizeLocalhostHostname("127.0.0.10", true)); + assertSame(value, PlatformTestUtils.normalizeLocalhostHostname(value, false)); + assertEquals( + OperatingSystem.isWindows() ? "localhost" : value, normalizeLocalhostHostname(value)); + } + + @Test + void normalizesOnlyTheWindowsUrlHost() { + String value = "http://127.0.0.1:8080/a/127.0.0.1?q=127.0.0.1#127.0.0.1"; + String normalized = "http://localhost:8080/a/127.0.0.1?q=127.0.0.1#127.0.0.1"; + + assertEquals(normalized, PlatformTestUtils.normalizeLocalhostUrl(value, true)); + assertSame(value, PlatformTestUtils.normalizeLocalhostUrl(value, false)); + assertEquals(OperatingSystem.isWindows() ? normalized : value, normalizeLocalhostUrl(value)); + assertEquals( + "http://127.0.0.10/test", + PlatformTestUtils.normalizeLocalhostUrl("http://127.0.0.10/test", true)); + assertEquals("not a url", PlatformTestUtils.normalizeLocalhostUrl("not a url", true)); + assertNull(PlatformTestUtils.normalizeLocalhostUrl(null, true)); + assertEquals( + "http://user:password@localhost:8080/test", + PlatformTestUtils.normalizeLocalhostUrl("http://user:password@127.0.0.1:8080/test", true)); + } +} diff --git a/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandRunnerTest.java b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandRunnerTest.java new file mode 100644 index 00000000000..610e5dad584 --- /dev/null +++ b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandRunnerTest.java @@ -0,0 +1,79 @@ +package datadog.trace.test.util; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the command dispatch. In production the runner only ever executes as a child + * process, where its behavior is covered end to end by the emulated cases of {@link + * PortableCommandTest} — but a child JVM is opaque to both the coverage report and to assertions + * about why a command misbehaved, so the dispatch is driven directly here. + */ +class PortableCommandRunnerTest { + @Test + void echoes() throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + PortableCommandRunner.execute(new String[] {"echo", "value"}, emptyInput(), output); + + assertEquals("value" + System.lineSeparator(), new String(output.toByteArray(), UTF_8)); + } + + @Test + void copiesInput() throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + InputStream input = new ByteArrayInputStream("payload".getBytes(UTF_8)); + + PortableCommandRunner.execute(new String[] {"cat"}, input, output); + + assertEquals("payload", new String(output.toByteArray(), UTF_8)); + } + + @Test + void sleeps() throws Exception { + long start = System.nanoTime(); + + PortableCommandRunner.execute( + new String[] {"sleep", "50"}, emptyInput(), new ByteArrayOutputStream()); + + assertTrue((System.nanoTime() - start) / 1_000_000 >= 40, "sleep returned immediately"); + } + + @Test + void rejectsMissingCommand() { + assertThrows( + IllegalArgumentException.class, + () -> + PortableCommandRunner.execute( + new String[0], emptyInput(), new ByteArrayOutputStream())); + } + + @Test + void rejectsUnknownCommand() { + assertThrows( + IllegalArgumentException.class, + () -> + PortableCommandRunner.execute( + new String[] {"rm"}, emptyInput(), new ByteArrayOutputStream())); + } + + @Test + void rejectsMissingArgument() { + assertThrows( + IllegalArgumentException.class, + () -> + PortableCommandRunner.execute( + new String[] {"echo"}, emptyInput(), new ByteArrayOutputStream())); + } + + private static InputStream emptyInput() { + return new ByteArrayInputStream(new byte[0]); + } +} diff --git a/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandTest.java b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandTest.java new file mode 100644 index 00000000000..784145e8c8b --- /dev/null +++ b/utils/test-utils/src/test/java/datadog/trace/test/util/PortableCommandTest.java @@ -0,0 +1,212 @@ +package datadog.trace.test.util; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import datadog.environment.OperatingSystem; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.tabletest.junit.TableTest; + +class PortableCommandTest { + private static final long TIMEOUT_SECONDS = 20; + + // Both strategies are exercised on every platform so that the emulated path, which only ships + // on Windows, is still verified by a POSIX CI run. + + @TableTest({ + "scenario | emulated", + "native command | false ", + "emulated child JVM | true " + }) + void testEcho(boolean emulated) throws Exception { + assumeStrategySupported(emulated); + + Result result = run(PortableCommand.echo("hello", emulated), null); + + assertEquals(0, result.exitCode, result.error); + assertEquals("hello" + System.lineSeparator(), result.output); + } + + @TableTest({ + "scenario | emulated", + "native command | false ", + "emulated child JVM | true " + }) + void testCat(boolean emulated) throws Exception { + assumeStrategySupported(emulated); + + Result result = run(PortableCommand.cat(emulated), "copied".getBytes(UTF_8)); + + assertEquals(0, result.exitCode, result.error); + assertEquals("copied", result.output); + } + + @TableTest({ + "scenario | emulated", + "native command | false ", + "emulated child JVM | true " + }) + void testSleep(boolean emulated) throws Exception { + assumeStrategySupported(emulated); + + long start = System.nanoTime(); + Result result = run(PortableCommand.sleep(1, emulated), null); + long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + + assertEquals(0, result.exitCode, result.error); + // A floor slightly below the requested duration keeps this robust against clock granularity + // while still failing if sleep is a no-op. + assertTrue(elapsedMillis >= 900, "slept for only " + elapsedMillis + "ms"); + } + + @TableTest({ + "scenario | emulated", + "native command | false ", + "emulated child JVM | true " + }) + void testRunForever(boolean emulated) throws Exception { + assumeStrategySupported(emulated); + + Process process = new ProcessBuilder(PortableCommand.runForever(emulated)).start(); + try { + assertFalse(process.waitFor(1, SECONDS), "runForever() must not exit on its own"); + + process.destroyForcibly(); + + assertTrue(process.waitFor(TIMEOUT_SECONDS, SECONDS), "process outlived destroyForcibly()"); + } finally { + process.destroyForcibly(); + } + } + + @Test + void publicApiSelectsStrategyForCurrentPlatform() { + boolean emulated = OperatingSystem.isWindows(); + + assertArrayEquals(PortableCommand.echo("v", emulated), PortableCommand.echo("v")); + assertArrayEquals(PortableCommand.cat(emulated), PortableCommand.cat()); + assertArrayEquals(PortableCommand.sleep(10, emulated), PortableCommand.sleep(10)); + assertArrayEquals(PortableCommand.runForever(emulated), PortableCommand.runForever()); + } + + @Test + void rejectsNegativeSleep() { + assertThrows(IllegalArgumentException.class, () -> PortableCommand.sleep(-1, false)); + assertThrows(IllegalArgumentException.class, () -> PortableCommand.sleep(-1, true)); + } + + @Test + void locatesJavaExecutableOfRunningJvm() { + Path executable = PortableCommand.javaExecutable(Paths.get(System.getProperty("java.home"))); + + assertTrue(Files.isRegularFile(executable), executable + " is not a file"); + } + + @Test + void locatesWindowsJavaExecutable(@TempDir Path javaHome) throws Exception { + Path bin = Files.createDirectory(javaHome.resolve("bin")); + Path executable = Files.createFile(bin.resolve("java.exe")); + + assertEquals(executable, PortableCommand.javaExecutable(javaHome)); + } + + @Test + void failsWhenJavaExecutableIsMissing(@TempDir Path javaHome) throws Exception { + Files.createDirectory(javaHome.resolve("bin")); + + assertThrows(IllegalStateException.class, () -> PortableCommand.javaExecutable(javaHome)); + } + + private static void assumeStrategySupported(boolean emulated) { + assumeTrue( + emulated || !OperatingSystem.isWindows(), + "native commands are only available on POSIX platforms"); + } + + private static Result run(String[] command, byte[] input) throws Exception { + Process process = new ProcessBuilder(command).start(); + try { + // Both streams are drained concurrently: waiting for the process to exit before reading + // deadlocks as soon as either pipe buffer fills. + Drain output = Drain.of(process.getInputStream()); + Drain error = Drain.of(process.getErrorStream()); + + try (OutputStream stdin = process.getOutputStream()) { + if (input != null) { + stdin.write(input); + } + } + + assertTrue( + process.waitFor(TIMEOUT_SECONDS, SECONDS), + () -> "command timed out: " + String.join(" ", command)); + return new Result(process.exitValue(), output.text(), error.text()); + } finally { + process.destroyForcibly(); + } + } + + private static final class Result { + final int exitCode; + final String output; + final String error; + + Result(int exitCode, String output, String error) { + this.exitCode = exitCode; + this.output = output; + this.error = error; + } + } + + private static final class Drain extends Thread { + private final InputStream input; + private final ByteArrayOutputStream output = new ByteArrayOutputStream(); + private volatile IOException failure; + + private Drain(InputStream input) { + this.input = input; + setDaemon(true); + } + + static Drain of(InputStream input) { + Drain drain = new Drain(input); + drain.start(); + return drain; + } + + @Override + public void run() { + byte[] buffer = new byte[8192]; + try { + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + } catch (IOException e) { + failure = e; + } + } + + String text() throws Exception { + join(SECONDS.toMillis(TIMEOUT_SECONDS)); + if (failure != null) { + throw failure; + } + return new String(output.toByteArray(), UTF_8); + } + } +}