Skip to content
Draft
2 changes: 1 addition & 1 deletion .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,7 @@ test_smoke_graalvm:
extends: .test_job
tags: [ "arch:amd64" ]
variables:
GRADLE_TARGET: "stageMainDist :dd-smoke-test:spring-boot-3.0-native:test"
GRADLE_TARGET: "stageMainDist :dd-smoke-test:spring-boot-3.0-native:test :dd-smoke-test:quarkus-native:test"
CACHE_TYPE: "smoke"
CI_NO_SPLIT: "true"
NON_DEFAULT_JVMS: "true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import org.gradle.api.file.FileTree
import org.gradle.api.file.RegularFile
import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.MapProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.IgnoreEmptyDirectories
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Nested
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.PathSensitive
Expand All @@ -37,6 +39,7 @@ import javax.inject.Inject
* `-P<propertyName>=<absolute-path>` and tracked as a task input so the nested build re-runs
* when the upstream jar changes.
*/
@CacheableTask
abstract class NestedGradleBuild @Inject constructor(
private val objects: ObjectFactory,
javaToolchains: JavaToolchainService,
Expand Down Expand Up @@ -74,6 +77,14 @@ abstract class NestedGradleBuild @Inject constructor(
@get:Input
abstract val buildArguments: ListProperty<String>

/**
* Extra environment variables for the nested Gradle daemon. Merged on top of the outer
* process environment — set a key to override an inherited value. The nested build script
* sees these via `System.getenv()` like any normal environment variable.
*/
@get:Input
abstract val environment: MapProperty<String, String>

@get:Nested
abstract val projectJars: ListProperty<NestedBuildProjectJar>

Expand Down Expand Up @@ -115,11 +126,16 @@ abstract class NestedGradleBuild @Inject constructor(
.useGradleVersion(gradleVersion.get())
.forProjectDirectory(appDir)

val extraEnv = environment.get()
val mergedEnv: Map<String, String>? =
if (extraEnv.isEmpty()) null else System.getenv() + extraEnv

connector.connect().use { connection ->
connection.newBuild()
.forTasks(*tasksToRun.get().toTypedArray())
.withArguments(args)
.setJavaHome(daemonJavaHome)
.apply { if (mergedEnv != null) setEnvironmentVariables(mergedEnv) }
.setStandardOutput(System.out)
.setStandardError(System.err)
.run()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ abstract class SmokeTestAppExtension @Inject constructor(
javaLauncher.set(this@SmokeTestAppExtension.javaLauncher)
tasksToRun.set(nestedTasks)
buildArguments.set(spec.buildArguments)
environment.set(spec.environment)
projectJars.set(this@SmokeTestAppExtension.projectJars)
}

Expand All @@ -117,16 +118,36 @@ abstract class SmokeTestAppExtension @Inject constructor(
* lazily — no `evaluationDependsOn` is needed.
*/
fun projectJar(propertyName: String, sourceProject: Project) {
val cfg = createExtraJarConfiguration(propertyName)
project.dependencies.add(cfg.name, sourceProject)
addProjectJarFromConfiguration(propertyName, cfg)
}

/**
* Forward a non-default artifact configuration from [sourceProject]. Use this when the
* upstream project exposes its build output under a configuration other than the default
* (e.g. `shadowJar`).
*/
fun projectJar(propertyName: String, sourceProject: Project, configuration: String) {
val cfg = createExtraJarConfiguration(propertyName)
project.dependencies.add(
cfg.name,
project.dependencies.project(
mapOf("path" to sourceProject.path, "configuration" to configuration),
),
)
addProjectJarFromConfiguration(propertyName, cfg)
}

private fun createExtraJarConfiguration(propertyName: String): Configuration {
val configurationName = "smokeTestAppExtraJar" +
propertyName.replaceFirstChar { it.titlecase(Locale.ROOT) }
val cfg = project.configurations.maybeCreate(configurationName).apply {
return project.configurations.maybeCreate(configurationName).apply {
isCanBeConsumed = false
isCanBeResolved = true
isTransitive = false
description = "Jar artifact forwarded as -P$propertyName into the smoke-test nested build"
}
project.dependencies.add(configurationName, sourceProject)
addProjectJarFromConfiguration(propertyName, cfg)
}

/**
Expand Down Expand Up @@ -176,6 +197,13 @@ abstract class ApplicationSpec @Inject constructor() {
/** Extra arguments passed to the nested Gradle invocation. */
abstract val buildArguments: ListProperty<String>

/**
* Extra environment variables exposed to the nested Gradle daemon. Merged on top of the
* outer process environment — entries here override any inherited values with the same key.
* Use this for nested tooling that reads `JAVA_HOME`, `GRAALVM_HOME`, etc. from the env.
*/
abstract val environment: MapProperty<String, String>

/**
* Additional system properties to forward to every `Test` task, keyed by property name with
* values resolved against `applicationBuildDir`. Use this for smoke tests that need more
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ interface TestJvmConstraintsExtension {
*/
val allowReflectiveAccessToJdk: Property<Boolean>

/**
* Require the JDK running the test (or the daemon JVM, when no `testJvm` is selected) to
* be `native-image` capable — i.e. a GraalVM-flavoured distribution that ships
* `lib/svm/bin/native-image`. Tasks running on JDKs that don't satisfy this requirement
* are skipped via `onlyIf`, matching the gating model used by [minJavaVersion] and
* [includeJdk]. Defaults to `false` (no native-image requirement).
*/
val nativeImageCapable: Property<Boolean>

companion object {
const val TEST_JVM_CONSTRAINTS = "testJvmConstraints"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package datadog.gradle.plugin.testJvmConstraints

import org.gradle.api.JavaVersion
import org.gradle.api.logging.Logging
import org.gradle.jvm.toolchain.JavaLauncher
import java.io.File
import java.nio.file.Files
import java.nio.file.Path

private val logger = Logging.getLogger("TestJvmConstraintsUtils")

Expand All @@ -28,6 +32,32 @@ internal fun TestJvmConstraintsExtension.isTestJvmAllowed(testJvmSpec: TestJvmSp
return true
}

/**
* When [TestJvmConstraintsExtension.nativeImageCapable] is `true`, verify the chosen test
* launcher ships the `native-image` tool. The actual binary lives under `lib/svm/bin/` on
* a GraalVM distribution; `bin/native-image` may be a symlink to it (or a `.cmd` shim on
* Windows), so probe all three. Returns `true` when the requirement is unset or satisfied,
* so the check is a safe no-op for tasks that haven't opted in.
*/
internal fun TestJvmConstraintsExtension.isNativeImageCapableTestJvm(launcher: JavaLauncher): Boolean {
if (!nativeImageCapable.getOrElse(false)) return true
return hasNativeImage(launcher.metadata.installationPath.asFile.toPath())
}

/**
* Daemon-side counterpart to [isNativeImageCapableTestJvm], used when no `testJvm` was
* selected — checks the running daemon's `java.home`. Same no-op semantics.
*/
internal fun TestJvmConstraintsExtension.isNativeImageCapableDaemon(): Boolean {
if (!nativeImageCapable.getOrElse(false)) return true
return hasNativeImage(File(System.getProperty("java.home")).toPath())
}

private fun hasNativeImage(installPath: Path): Boolean =
Files.exists(installPath.resolve("lib/svm/bin/native-image")) ||
Files.exists(installPath.resolve("bin/native-image")) ||
Files.exists(installPath.resolve("bin/native-image.cmd"))

private fun TestJvmConstraintsExtension.withinAllowedRange(currentJvmVersion: JavaVersion): Boolean {
val definedMin = minJavaVersion.isPresent
val definedMax = maxJavaVersion.isPresent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension
import datadog.gradle.plugin.testJvmConstraints.TestJvmConstraintsExtension.Companion.TEST_JVM_CONSTRAINTS
import datadog.gradle.plugin.testJvmConstraints.TestJvmSpec
import datadog.gradle.plugin.testJvmConstraints.isJavaVersionAllowed
import datadog.gradle.plugin.testJvmConstraints.isNativeImageCapableDaemon
import datadog.gradle.plugin.testJvmConstraints.isNativeImageCapableTestJvm
import datadog.gradle.plugin.testJvmConstraints.isTestJvmAllowed

plugins {
Expand Down Expand Up @@ -30,6 +32,7 @@ tasks.withType<Test>().configureEach {
inputs.property("$TEST_JVM_CONSTRAINTS.forceJdk", taskExtension.forceJdk)
inputs.property("$TEST_JVM_CONSTRAINTS.minJavaVersion", taskExtension.minJavaVersion).optional(true)
inputs.property("$TEST_JVM_CONSTRAINTS.maxJavaVersion", taskExtension.maxJavaVersion).optional(true)
inputs.property("$TEST_JVM_CONSTRAINTS.nativeImageCapable", taskExtension.nativeImageCapable).optional(true)

extensions.add(TEST_JVM_CONSTRAINTS, taskExtension)

Expand Down Expand Up @@ -61,13 +64,19 @@ fun Test.conditionalJvmArgs(
private fun Test.configureTestJvm(extension: TestJvmConstraintsExtension) {
if (testJvmSpec.javaTestLauncher.isPresent) {
javaLauncher = testJvmSpec.javaTestLauncher
onlyIf("Allowed or forced JDK") {
onlyIf("Test JDK is allowed or forced JDK") {
extension.isTestJvmAllowed(testJvmSpec)
}
onlyIf("Test JDK is native-image capable") {
extension.isNativeImageCapableTestJvm(testJvmSpec.javaTestLauncher.get())
}
} else {
onlyIf("Is current Daemon JVM allowed") {
onlyIf("Current Daemon JVM within allowed version range") {
extension.isJavaVersionAllowed(JavaVersion.current())
}
onlyIf("Current Daemon JVM is native-image capable") {
extension.isNativeImageCapableDaemon()
}
}

// temporary workaround when using Java16+: some tests require reflective access to java.lang/java.util
Expand Down Expand Up @@ -125,4 +134,5 @@ private fun Test.configureConventions(
taskExtension.allowReflectiveAccessToJdk.convention(projectExtension.allowReflectiveAccessToJdk
.orElse(providers.provider { project.findProperty("allowReflectiveAccessToJdk") as? Boolean })
)
taskExtension.nativeImageCapable.convention(projectExtension.nativeImageCapable)
}
15 changes: 15 additions & 0 deletions dd-smoke-tests/quarkus-native/application/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,18 @@ dependencies {
}
}

if (hasProperty('agentJar')) {
// The Quarkus Gradle plugin reads `quarkus.native.additional-build-args` from
// MicroProfile Config, which honours system properties. Setting it at script
// evaluation time is equivalent to passing `-Dquarkus.native.additional-build-args=...`
// on the gradle invocation, but keeps the agent jar path resolution inside this
// script (where `agentJar` is forwarded by the outer smoke-test plugin).
final agentJar = property('agentJar')
System.setProperty(
'quarkus.native.additional-build-args',
"-J-javaagent:${agentJar}," +
"-J-Ddatadog.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd'T'HH:mm:ss.SSS'Z [dd.trace]'," +
"-J-Ddd.profiling.enabled=true," +
"-march=native"
)
}
119 changes: 60 additions & 59 deletions dd-smoke-tests/quarkus-native/build.gradle
Original file line number Diff line number Diff line change
@@ -1,81 +1,82 @@
import java.util.concurrent.Callable
import java.util.regex.Pattern

plugins {
id 'dd-trace-java.smoke-test-app'
}

apply from: "$rootDir/gradle/java.gradle"

description = 'Quarkus Native Smoke Tests.'

dependencies {
testImplementation project(':dd-smoke-tests')
testImplementation libs.bundles.jmc
}
// Check 'testJvm' gradle command parameter is GraalVM (e.g., -PtestJvm=graalvm21),
// if not nothing is done

// Quarkus 3.12.1's native-image step requires at least Mandrel/GraalVM 23.1 (JDK 21-based)
// and rejects the JDK-17 distribution with "You are using an older version of GraalVM or
// Mandrel". Cap to a native-image-capable JDK 21+ launcher so unsupported slots skip at
// the constraint layer rather than failing inside the nested build. Verified locally on
// `graalvm21` and `graalvm25` — both produce a working native binary.
testJvmConstraints {
minJavaVersion = JavaVersion.VERSION_21
nativeImageCapable = true
}

// `quarkusNativeBuild` requires an explicit `-PtestJvm=graalvm21`. Parsed once, then used
// both as the GRAALVM_HOME env var for the nested build (via `getLazyJavaHomeFor` — defers
// toolchain provisioning + applies the native-image symlink fix) and to gate the native
// build / test compile tasks so they skip gracefully when no supported GraalVM was selected.
def testJvm = gradle.startParameter.projectProperties.getOrDefault('testJvm', '')
def graalvmMatcher = Pattern.compile("graalvm([0-9]+)").matcher(testJvm?.toLowerCase(Locale.ROOT) ?: '')
def testGraalvmVersion = graalvmMatcher.find() ? Integer.parseInt(graalvmMatcher.group(1)) : -1
def isSupportedGraalvm = testGraalvmVersion >= 21
Provider<String> graalvmHome = providers.provider({
if (!isSupportedGraalvm) {
throw new GradleException("Set -PtestJvm=graalvm<N> (N >= 21) to build the native image for this smoke test.")
}
getLazyJavaHomeFor(testGraalvmVersion, true).toString()
} as Callable<String>)

if (testGraalvmVersion >= 17) {
// GraalVM with native-image capability for native compilation
def graalvmHome = getLazyJavaHomeFor(testGraalvmVersion, true)
// For GraalVM 21+ we need Java 17+ to run Gradle, for earlier versions use GraalVM itself
def gradleJavaHome = testGraalvmVersion >= 21 ? getLazyJavaHomeFor(17) : graalvmHome
// Configure build directory for application
def appDir = "$projectDir/application"
def appBuildDir = "$buildDir/application"
def isWindows = System.getProperty('os.name').toLowerCase().contains('win')
def gradlewCommand = isWindows ? 'gradlew.bat' : 'gradlew'

// Define the task that builds the project
tasks.register('quarkusNativeBuild', Exec) {
workingDir "$appDir"
environment += [
'GRADLE_OPTS' : "-Dorg.gradle.jvmargs='-Xmx512M'",
'JAVA_HOME' : gradleJavaHome,
'GRAALVM_HOME': graalvmHome
]
commandLine(
"$rootDir/${gradlewCommand}",
'build',
'--no-daemon',
smokeTestApp {
application {
taskName = 'quarkusNativeBuild'
nestedTasks = ['build']
artifactPath = 'quarkus-native-smoketest--runner'
sysProperty = 'datadog.smoketest.quarkus.native.executable'
buildArguments.addAll(
'--max-workers=4',
"-Dquarkus.native.enabled=true",
"-Dquarkus.package.jar.enabled=false",
"-PappBuildDir=$appBuildDir",
"-PapiJar=${project(':dd-trace-api').tasks.jar.archiveFile.get()}",
"-Dquarkus.native.additional-build-args=-J-javaagent:${project(':dd-java-agent').tasks.shadowJar.archiveFile.get()}," +
"-J-Ddatadog.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd'T'HH:mm:ss.SSS'Z [dd.trace]'," +
"-J-Ddd.profiling.enabled=true,-march=native"
'-Dquarkus.native.enabled=true',
'-Dquarkus.package.jar.enabled=false',
)
outputs.cacheIf { true }
outputs.dir(appBuildDir)
.withPropertyName('nativeApplication')
inputs.files(fileTree(appDir) {
include '**/*'
exclude '.gradle/**'
}).withPropertyName('application')
.withPathSensitivity(PathSensitivity.RELATIVE)
inputs.file(project(':dd-trace-api').tasks.jar.archiveFile.get()).withPropertyName('apiJar')
inputs.file(project(':dd-java-agent').tasks.shadowJar.archiveFile.get()).withPropertyName('agentJar')
environment.put('GRAALVM_HOME', graalvmHome)
}
projectJar('apiJar', project(':dd-trace-api'))
projectJar('agentJar', project(':dd-java-agent'), 'shadow')
}

tasks.named('quarkusNativeBuild') {
dependsOn project(':dd-trace-api').tasks.named("jar") // Use dev @Trace annotation
dependsOn project(':dd-java-agent').tasks.named('shadowJar') // Use dev agent
}
tasks.named('quarkusNativeBuild') {
onlyIf("Requires -PtestJvm=graalvm<N> (N >= 21)") { isSupportedGraalvm }
}

tasks.named('compileTestGroovy') {
dependsOn 'quarkusNativeBuild'
outputs.upToDateWhen {
!quarkusNativeBuild.didWork
}
tasks.named('compileTestGroovy', GroovyCompile) {
dependsOn 'quarkusNativeBuild'
onlyIf { isSupportedGraalvm }
outputs.upToDateWhen {
!tasks.named('quarkusNativeBuild').get().didWork
}
}

tasks.withType(Test).configureEach {
jvmArgs "-Ddatadog.smoketest.quarkus.native.executable=$appBuildDir/quarkus-native-smoketest--runner"
jvmArgs "-Ddd.profiling.enabled=true"
}
} else {
tasks.withType(Test).configureEach {
enabled = false
}
tasks.withType(Test).configureEach {
// Skip when no supported GraalVM was selected — `testJvmConstraints` only checks for a
// native-image-capable launcher, so a daemon JVM that happens to be GraalVM 21 would
// otherwise let the Test task run against stale or missing artifacts.
onlyIf("Requires -PtestJvm=graalvm<N> (N >= 21)") { isSupportedGraalvm }
// These smoke tests don't need to be launched from the test, since what is exercised is a native binary
// This resets the launcher to the daemon JDK
javaLauncher = javaToolchains.launcherFor(java.toolchain)
jvmArgs "-Ddd.profiling.enabled=true"
}

spotless {
Expand Down
Loading