diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 7644f991..35901048 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -9,6 +9,7 @@ repositories { } dependencies { + implementation("biz.aQute.bnd:biz.aQute.bnd.gradle:7.3.0") implementation("com.diffplug.spotless:spotless-plugin-gradle:8.8.0") implementation("ru.vyarus:gradle-animalsniffer-plugin:2.0.1") implementation("me.champeau.gradle:japicmp-gradle-plugin:0.4.6") diff --git a/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelJavaExtension.kt b/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelJavaExtension.kt index bf584e71..99cbd6d2 100644 --- a/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelJavaExtension.kt +++ b/buildSrc/src/main/kotlin/io/opentelemetry/gradle/OtelJavaExtension.kt @@ -5,8 +5,15 @@ package io.opentelemetry.gradle +import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property abstract class OtelJavaExtension { abstract val moduleName: Property + + /** Whether to generate OSGi bundle metadata. Enabled by default. */ + abstract val osgiEnabled: Property + + /** Extra packages imported as optional (resolution:=optional), e.g. compileOnly deps. */ + abstract val osgiOptionalPackages: ListProperty } diff --git a/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts b/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts index 104f8ec5..7f1b96b5 100644 --- a/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/otel.java-conventions.gradle.kts @@ -8,10 +8,13 @@ plugins { eclipse idea + id("biz.aQute.bnd.builder") id("otel.spotless-conventions") } val otelJava = extensions.create("otelJava") +otelJava.osgiEnabled.convention(true) +otelJava.osgiOptionalPackages.convention(emptyList()) java { toolchain { @@ -29,6 +32,13 @@ checkstyle { configProperties["rootDir"] = rootDir } +// normalize timestamps and file ordering in jars, making the outputs (including OSGi +// manifests) reproducible. see open-telemetry/opentelemetry-java#4488 +tasks.withType().configureEach { + isPreserveFileTimestamps = false + isReproducibleFileOrder = true +} + val testJavaVersion = gradle.startParameter.projectProperties.get("testJavaVersion")?.let(JavaVersion::toVersion) tasks { @@ -85,6 +95,30 @@ tasks { } } + afterEvaluate { + if (otelJava.osgiEnabled.get()) { + named("jar") { + // Configure OSGi metadata. semconv has no SPI / ServiceLoader needs, so this is the + // trimmed form of opentelemetry-java's otel.java-conventions OSGi config. + bundle { + // Modules may declare optional imports (typically compileOnly deps). The trailing + // "*" lets BND auto-import everything else (e.g. io.opentelemetry.api.*). + val optionalPackages = otelJava.osgiOptionalPackages.get() + val optionalImports = + optionalPackages.joinToString(",") { "$it.*;resolution:=optional;version=\"\${@}\"" } + val importPackages = if (optionalImports.isEmpty()) "*" else "$optionalImports,*" + + bnd( + mapOf( + "-exportcontents" to "io.opentelemetry.*", + "Import-Package" to importPackages, + ), + ) + } + } + } + } + withType().configureEach { inputs.property("moduleName", otelJava.moduleName) diff --git a/osgi-test/build.gradle.kts b/osgi-test/build.gradle.kts new file mode 100644 index 00000000..8ab6c336 --- /dev/null +++ b/osgi-test/build.gradle.kts @@ -0,0 +1,125 @@ +import aQute.bnd.gradle.Bundle +import aQute.bnd.gradle.Resolve +import aQute.bnd.gradle.TestOSGi + +plugins { + id("otel.java-conventions") +} + +description = "OpenTelemetry Semantic Conventions OSGi Integration Tests" +otelJava.moduleName.set("io.opentelemetry.semconv.integration.tests.osgi") +// This module runs OSGi tests against the published semconv bundles; it does not publish an OSGi +// bundle itself. +otelJava.osgiEnabled.set(false) + +// The semconv bundles Import-Package io.opentelemetry.api.common, so resolution needs an +// opentelemetry-api that is itself a proper OSGi bundle. OSGi metadata was added to the core +// artifacts in opentelemetry-java#8417, well after the 1.33.0 compileOnly baseline pinned in +// :dependencyManagement, so we force a recent version on this test module's classpath only. +val osgiOtelApiVersion = "1.63.0" + +configurations.configureEach { + resolutionStrategy { + force("io.opentelemetry:opentelemetry-api:$osgiOtelApiVersion") + } +} + +dependencies { + testImplementation(project(":semconv")) + testImplementation(project(":semconv-incubating")) + // Provides the io.opentelemetry.api.* packages the semconv bundles import, as an OSGi bundle. + testImplementation("io.opentelemetry:opentelemetry-api:$osgiOtelApiVersion") + + testImplementation("org.osgi:org.osgi.test.junit5:1.3.0") + // Provided by the OSGi framework at runtime. + testCompileOnly("org.osgi:osgi.core:8.0.0") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly("org.apache.felix:org.apache.felix.framework:7.0.5") + // opentelemetry-common (pulled in transitively by opentelemetry-api) declares + // Require-Capability: osgi.extender=osgi.serviceloader.processor, so the container needs Aries + // SPI Fly to resolve even though semconv itself has no ServiceLoader providers. + testRuntimeOnly("org.apache.aries.spifly:org.apache.aries.spifly.dynamic.bundle:1.3.7") +} + +// The testing bundle (our JUnit tests + Test-Cases header) is booted inside a real Felix container +// via bnd's Bundle -> Resolve -> TestOSGi task chain, modeled on opentelemetry-java's +// integration-tests/osgi module. +val bsn = "opentelemetry-semconv-osgi-testing" +val runee = "JavaSE-${java.toolchain.languageVersion.get()}" +val testRuntimeClasspath = sourceSets.test.get().runtimeClasspath + +val testingBundle = tasks.register("testingBundle") { + archiveClassifier.set("testing") + from(sourceSets.test.get().output) + bundle { + // BND analyses compileClasspath by default; use the runtime classpath so testImplementation + // deps (junit-jupiter, assertj) are visible and Test-Cases gets populated. + classpath(testRuntimeClasspath) + bnd( + "Bundle-SymbolicName: $bsn", + "Test-Cases: \${classes;HIERARCHY_INDIRECTLY_ANNOTATED;org.junit.platform.commons.annotation.Testable;CONCRETE}", + ) + } +} + +val inputBndrun = layout.buildDirectory.file("bndrun/test.bndrun") +val generateBndrun = tasks.register("generateBndrun") { + // Local copies so the doLast closure captures only serializable values (String, Provider), not + // the enclosing Kotlin build-script object (required by the configuration cache). + val bndrunFile = inputBndrun + val bndrunContent = + """ + |-tester: biz.aQute.tester.junit-platform + |-runfw: org.apache.felix.framework + |-runee: $runee + | + |-runrequires: \ + | bnd.identity;id='$bsn',\ + | bnd.identity;id='junit-jupiter-engine',\ + | bnd.identity;id='junit-platform-launcher' + """.trimMargin() + inputs.property("content", bndrunContent) + outputs.file(bndrunFile) + doLast { + bndrunFile.get().asFile.apply { parentFile.mkdirs() }.writeText(bndrunContent) + } +} + +val resolvedBndrun = layout.buildDirectory.file("test.bndrun") +val resolve = tasks.register("resolve") { + dependsOn(testingBundle, generateBndrun) + description = "Resolve the semconv OSGi test suite" + group = JavaBasePlugin.VERIFICATION_GROUP + bndrun = inputBndrun.get().asFile + outputBndrun = resolvedBndrun + bundles = files(testRuntimeClasspath, testingBundle.get().archiveFile) + // The resolved bndrun embeds an absolute path to the input, so it is not safe to share via cache. + outputs.cacheIf { false } +} + +tasks.register("testOsgi") { + dependsOn(resolve) + description = "Run the semconv OSGi test suite inside an Apache Felix container" + group = JavaBasePlugin.VERIFICATION_GROUP + bndrun = resolvedBndrun + bundles = files(testRuntimeClasspath, testingBundle.get().archiveFile) + // BND reports success when zero tests ran (e.g. if bundles failed to start). Fail explicitly. + val testResultsDir = layout.buildDirectory.dir("test-results/$name") + doLast { + check(testResultsDir.get().asFile.listFiles()?.isNotEmpty() == true) { + "No OSGi test results found — bundles may have failed to start. Check the output above." + } + } +} + +tasks { + named("jar") { + enabled = false + } + named("test") { + // Replace plain JUnit execution with the in-container OSGi test. + actions.clear() + dependsOn("testOsgi") + } +} diff --git a/osgi-test/src/test/java/io/opentelemetry/semconv/integrationtest/osgi/SemconvOsgiTest.java b/osgi-test/src/test/java/io/opentelemetry/semconv/integrationtest/osgi/SemconvOsgiTest.java new file mode 100644 index 00000000..09b8bfb8 --- /dev/null +++ b/osgi-test/src/test/java/io/opentelemetry/semconv/integrationtest/osgi/SemconvOsgiTest.java @@ -0,0 +1,39 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.semconv.integrationtest.osgi; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.semconv.SchemaUrls; +import io.opentelemetry.semconv.ServiceAttributes; +import io.opentelemetry.semconv.incubating.ServiceIncubatingAttributes; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.osgi.test.junit5.context.BundleContextExtension; + +/** + * Boots the semconv and semconv-incubating bundles inside a real OSGi (Apache Felix) container and + * exercises their exported packages. If either bundle's generated OSGi metadata is wrong (missing + * exports, unsatisfiable imports), the bundle fails to resolve and these tests never run — which + * the build treats as a failure. + */ +@ExtendWith(BundleContextExtension.class) +public class SemconvOsgiTest { + + @Test + public void stableAttributesAreUsable() { + AttributeKey serviceName = ServiceAttributes.SERVICE_NAME; + assertThat(serviceName.getKey()).isEqualTo("service.name"); + assertThat(SchemaUrls.V1_41_1).isEqualTo("https://opentelemetry.io/schemas/1.41.1"); + } + + @Test + public void incubatingAttributesAreUsable() { + AttributeKey instanceId = ServiceIncubatingAttributes.SERVICE_INSTANCE_ID; + assertThat(instanceId.getKey()).isEqualTo("service.instance.id"); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 7c3fc3a7..0cdd3f6a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -60,3 +60,4 @@ rootProject.name = "semantic-conventions-java" include(":dependencyManagement") include(":semconv-incubating") include(":semconv") +include(":osgi-test")