diff --git a/.evergreen/static-checks.sh b/.evergreen/static-checks.sh index 1accf5c168..ef31420e61 100755 --- a/.evergreen/static-checks.sh +++ b/.evergreen/static-checks.sh @@ -13,3 +13,6 @@ echo "Compiling JVM drivers" ./gradlew -version ./gradlew -PxmlReports.enabled=true --info -x test -x integrationTest -x spotlessApply clean check scalaCheck jar testClasses docs + +echo "Running OSGi bundle resolution tests" +./gradlew :testing:osgi-test:check diff --git a/driver-core/build.gradle.kts b/driver-core/build.gradle.kts index 282c478858..047b3a43a6 100644 --- a/driver-core/build.gradle.kts +++ b/driver-core/build.gradle.kts @@ -102,6 +102,7 @@ configureJarManifest { "org.bson.codecs.record.*;resolution:=optional", // Depends on JDK version "org.bson.codecs.kotlin.*;resolution:=optional", "org.bson.codecs.kotlinx.*;resolution:=optional", + "io.micrometer.*;resolution:=optional", "*" // import all that is not excluded or modified before ) .joinToString(",") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 79d64ac520..c7898a7bdc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,7 @@ scala-v2-v11 = "2.11.12" # Test assertj = "3.24.2" +felix-framework = "7.0.5" aws-lambda-core = "1.2.2" aws-lambda-events = "3.11.1" cglib = "2.2.2" @@ -174,6 +175,7 @@ aws-lambda-core = { module = " com.amazonaws:aws-lambda-java-core", version.ref aws-lambda-events = { module = " com.amazonaws:aws-lambda-java-events", version.ref = "aws-lambda-events" } cglib = { module = "cglib:cglib-nodep", version.ref = "cglib" } classgraph = { module = "io.github.classgraph:classgraph", version.ref = "classgraph" } +felix-framework = { module = "org.apache.felix:org.apache.felix.framework", version.ref = "felix-framework" } findbugs-jsr = { module = "com.google.code.findbugs:jsr305", version.ref = "findbugs-jsr" } groovy = { module = "org.codehaus.groovy:groovy-all", version.ref = "groovy" } hamcrest-all = { module = "org.hamcrest:hamcrest-all", version.ref = "hamcrest" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 29d17792ad..896e770724 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,6 +42,7 @@ include(":driver-kotlin-sync") include(":driver-scala") include(":driver-benchmarks") +include(":testing:osgi-test") include(":driver-lambda") if (providers.gradleProperty("includeGraalvm").isPresent) { include(":graalvm-native-image-app") diff --git a/testing/osgi-test/build.gradle.kts b/testing/osgi-test/build.gradle.kts new file mode 100644 index 0000000000..a01b5d7ed6 --- /dev/null +++ b/testing/osgi-test/build.gradle.kts @@ -0,0 +1,41 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id("java-library") + id("project.base") + id("checkstyle") + id("conventions.testing-base") +} + +java { + toolchain { languageVersion = JavaLanguageVersion.of(17) } +} + +dependencies { + testImplementation(platform(libs.junit.bom)) + testImplementation(libs.junit.jupiter) + testImplementation(libs.junit.jupiter.platform.launcher) + testImplementation(libs.assertj) + testImplementation(libs.felix.framework) + testImplementation(libs.reactive.streams) + testImplementation(platform(libs.project.reactor.bom)) + testImplementation(libs.project.reactor.core) +} + +tasks.test { + dependsOn(":bson:jar", ":driver-core:jar", ":driver-sync:jar", ":driver-reactive-streams:jar") + systemProperty("projectRoot", rootProject.projectDir.absolutePath) +} diff --git a/testing/osgi-test/src/test/java/com/mongodb/osgi/OsgiBundleResolutionTest.java b/testing/osgi-test/src/test/java/com/mongodb/osgi/OsgiBundleResolutionTest.java new file mode 100644 index 0000000000..1266746a94 --- /dev/null +++ b/testing/osgi-test/src/test/java/com/mongodb/osgi/OsgiBundleResolutionTest.java @@ -0,0 +1,224 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.mongodb.osgi; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.felix.framework.FrameworkFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.BundleException; +import org.osgi.framework.launch.Framework; + +class OsgiBundleResolutionTest { + + private static final Path PROJECT_ROOT = Paths.get(System.getProperty("projectRoot", "../..")); + + private static final String[] BUNDLE_MODULES = { + "bson", + "driver-core", + "driver-sync", + "driver-reactive-streams" + }; + + @TempDir + private Path cacheDir; + + private Framework framework; + + @BeforeEach + void startFramework() throws BundleException, IOException { + + Map config = new HashMap<>(); + config.put("org.osgi.framework.storage", cacheDir.toString()); + config.put("org.osgi.framework.storage.clean", "onFirstInit"); + config.put("felix.log.level", "1"); + // Export required (non-optional) third-party packages from the system bundle. + // In a real OSGi container these would be provided by separately installed bundles. + // We scan the test classpath for reactive-streams and reactor-core JARs and export + // their packages so versions stay in sync with the version catalog automatically. + String extraPackages = buildSystemPackagesFromClasspath(); + if (!extraPackages.isEmpty()) { + config.put("org.osgi.framework.system.packages.extra", extraPackages); + } + + framework = new FrameworkFactory().newFramework(config); + framework.start(); + } + + @AfterEach + void stopFramework() throws BundleException, InterruptedException { + if (framework != null) { + framework.stop(); + framework.waitForStop(10_000); + } + } + + @Test + void bundlesResolveWithoutOptionalDependencies() throws Exception { + BundleContext ctx = framework.getBundleContext(); + List installed = new ArrayList<>(); + + for (String module : BUNDLE_MODULES) { + File jar = findBundleJar(module); + try (InputStream is = Files.newInputStream(jar.toPath())) { + Bundle bundle = ctx.installBundle("file:" + jar.getAbsolutePath(), is); + installed.add(bundle); + } + } + + for (Bundle bundle : installed) { + try { + bundle.start(); + } catch (BundleException e) { + fail(formatBundleFailure(bundle, e)); + } + } + } + + @Test + void bundlesReportCorrectSymbolicNames() throws Exception { + BundleContext ctx = framework.getBundleContext(); + List symbolicNames = new ArrayList<>(); + + for (String module : BUNDLE_MODULES) { + File jar = findBundleJar(module); + try (InputStream is = Files.newInputStream(jar.toPath())) { + Bundle bundle = ctx.installBundle("file:" + jar.getAbsolutePath(), is); + symbolicNames.add(bundle.getSymbolicName()); + } + } + + assertThat(symbolicNames).containsExactly( + "org.mongodb.bson", + "org.mongodb.driver-core", + "org.mongodb.driver-sync", + "org.mongodb.driver-reactivestreams"); + } + + private static String formatBundleFailure(final Bundle bundle, final BundleException e) { + String msg = e.getMessage(); + StringBuilder sb = new StringBuilder(); + sb.append("\n\n====================================================================\n"); + sb.append("BUNDLE RESOLUTION FAILURE: ").append(bundle.getSymbolicName()).append("\n"); + sb.append("====================================================================\n"); + + if (msg != null && msg.contains("missing requirement")) { + int pkgStart = msg.indexOf("osgi.wiring.package="); + if (pkgStart >= 0) { + String remainder = msg.substring(pkgStart + "osgi.wiring.package=".length()); + int pkgEnd = remainder.indexOf(')'); + String missingPackage = pkgEnd >= 0 ? remainder.substring(0, pkgEnd) : remainder; + sb.append("Missing required package: ").append(missingPackage).append("\n\n"); + sb.append("FIX: Add '").append(missingPackage).append(".*;resolution:=optional' to the\n"); + sb.append(" Import-Package list in the module's build.gradle.kts\n"); + } + } + + sb.append("\nFull error: ").append(msg); + sb.append("\n====================================================================\n"); + return sb.toString(); + } + + private static String systemPackagesCache; + + private static String buildSystemPackagesFromClasspath() { + if (systemPackagesCache != null) { + return systemPackagesCache; + } + Set packages = new LinkedHashSet<>(); + String classpath = System.getProperty("java.class.path", ""); + + for (String entry : classpath.split(File.pathSeparator)) { + File file = new File(entry); + String name = file.getName(); + if (!name.startsWith("reactive-streams") && !name.startsWith("reactor-core")) { + continue; + } + if (!file.isFile() || !name.endsWith(".jar")) { + continue; + } + try (JarFile jar = new JarFile(file)) { + String version = jar.getManifest().getMainAttributes().getValue("Bundle-Version"); + if (version == null) { + version = "0.0.0"; + } + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry jarEntry = entries.nextElement(); + String entryName = jarEntry.getName(); + if (entryName.endsWith(".class") && entryName.contains("/")) { + String pkg = entryName.substring(0, entryName.lastIndexOf('/')).replace('/', '.'); + packages.add(pkg + ";version=\"" + version + "\""); + } + } + } catch (IOException e) { + // Skip JARs that can't be read + } + } + + systemPackagesCache = String.join(",", packages); + return systemPackagesCache; + } + + private static File findBundleJar(final String module) { + Path libsDir = PROJECT_ROOT.resolve(module).resolve("build").resolve("libs"); + assertThat(libsDir) + .as("Build output directory for module '%s' must exist. Run ./gradlew jar first.", module) + .isDirectory(); + + try (Stream files = Files.list(libsDir)) { + List candidates = files + .filter(p -> p.getFileName().toString().endsWith(".jar")) + .filter(p -> !p.getFileName().toString().contains("-test")) + .filter(p -> !p.getFileName().toString().contains("-sources")) + .filter(p -> !p.getFileName().toString().contains("-javadoc")) + .map(Path::toFile) + .collect(Collectors.toList()); + + assertThat(candidates) + .as("Expected exactly one main JAR in %s", libsDir) + .hasSize(1); + + return candidates.get(0); + } catch (IOException e) { + return fail("Failed to list JARs in " + libsDir + ": " + e.getMessage()); + } + } +}