Skip to content

Commit 5bca6e4

Browse files
rozzavbabanin
andauthored
Mark micrometer OSGi imports as optional and add bundle resolution test (#1982) (#1985)
The micrometer-observation dependency is optional but its packages were imported as required in the OSGi metadata, causing bundle resolution failure in OSGi containers. Adds io.micrometer.*;resolution:=optional to driver-core Import-Package. Also adds a new testing/osgi-test module that uses Apache Felix to verify all driver bundles resolve without optional dependencies present. This runs in CI via static-checks.sh to prevent future regressions. JAVA-6215 --------- Co-authored-by: Viacheslav Babanin <frest0512@gmail.com>
1 parent 8a1b87a commit 5bca6e4

7 files changed

Lines changed: 360 additions & 1 deletion

File tree

.evergreen/static-checks.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ echo "Compiling JVM drivers"
1313

1414
./gradlew -version
1515
./gradlew -PxmlReports.enabled=true --info -x test -x integrationTest -x spotlessApply clean check scalaCheck jar testClasses docs
16+
17+
echo "Running OSGi bundle resolution tests"
18+
./gradlew -PxmlReports.enabled=true --info :testing:osgi-test:check

driver-core/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ configureJarManifest {
102102
"org.bson.codecs.record.*;resolution:=optional", // Depends on JDK version
103103
"org.bson.codecs.kotlin.*;resolution:=optional",
104104
"org.bson.codecs.kotlinx.*;resolution:=optional",
105+
"io.micrometer.*;resolution:=optional",
105106
"*" // import all that is not excluded or modified before
106107
)
107108
.joinToString(",")

gradle/libs.versions.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ scala-v2-v11 = "2.11.12"
3939

4040
# Test
4141
assertj = "3.24.2"
42+
felix-framework = "7.0.5"
4243
aws-lambda-core = "1.2.2"
4344
aws-lambda-events = "3.11.1"
4445
cglib = "2.2.2"
@@ -102,7 +103,7 @@ micrometer-observation = { module = "io.micrometer:micrometer-observation" }
102103
graal-sdk = { module = "org.graalvm.sdk:graal-sdk", version.ref = "graal-sdk" }
103104
graal-sdk-nativeimage = { module = "org.graalvm.sdk:nativeimage", version.ref = "graal-sdk" }
104105

105-
kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom" }
106+
kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" }
106107
kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8" }
107108
kotlinx-coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version.ref = "kotlinx-coroutines-bom" }
108109
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" }
@@ -174,6 +175,7 @@ aws-lambda-core = { module = " com.amazonaws:aws-lambda-java-core", version.ref
174175
aws-lambda-events = { module = " com.amazonaws:aws-lambda-java-events", version.ref = "aws-lambda-events" }
175176
cglib = { module = "cglib:cglib-nodep", version.ref = "cglib" }
176177
classgraph = { module = "io.github.classgraph:classgraph", version.ref = "classgraph" }
178+
felix-framework = { module = "org.apache.felix:org.apache.felix.framework", version.ref = "felix-framework" }
177179
findbugs-jsr = { module = "com.google.code.findbugs:jsr305", version.ref = "findbugs-jsr" }
178180
groovy = { module = "org.codehaus.groovy:groovy-all", version.ref = "groovy" }
179181
hamcrest-all = { module = "org.hamcrest:hamcrest-all", version.ref = "hamcrest" }

settings.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ include(":driver-kotlin-sync")
4242
include(":driver-scala")
4343

4444
include(":driver-benchmarks")
45+
include(":testing:osgi-test")
4546
include(":driver-lambda")
4647
if (providers.gradleProperty("includeGraalvm").isPresent) {
4748
include(":graalvm-native-image-app")

testing/osgi-test/build.gradle.kts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2008-present MongoDB, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
plugins {
17+
id("project.base")
18+
id("checkstyle")
19+
id("conventions.testing-base")
20+
}
21+
22+
java {
23+
toolchain { languageVersion = JavaLanguageVersion.of(17) }
24+
}
25+
26+
dependencies {
27+
testImplementation(platform(libs.junit.bom))
28+
testImplementation(libs.junit.jupiter)
29+
testImplementation(libs.junit.jupiter.platform.launcher)
30+
// AssertJ used here for infrastructure assertions (isDirectory, hasSize, containsExactly)
31+
// which are significantly more readable than JUnit 5 equivalents for this test.
32+
testImplementation(libs.assertj)
33+
testImplementation(libs.felix.framework)
34+
35+
// These JARs are scanned by buildSystemPackagesFromClasspath() to export packages
36+
// from the Felix system bundle, satisfying non-optional imports from bundles under test.
37+
testImplementation(libs.reactive.streams)
38+
testImplementation(platform(libs.project.reactor.bom))
39+
testImplementation(libs.project.reactor.core)
40+
testImplementation(platform(libs.kotlin.bom))
41+
testImplementation(libs.kotlin.stdlib.jdk8)
42+
testImplementation(libs.kotlin.reflect)
43+
testImplementation(platform(libs.kotlinx.coroutines.bom))
44+
testImplementation(libs.kotlinx.coroutines.core)
45+
testImplementation(libs.kotlinx.coroutines.reactive)
46+
testImplementation(libs.findbugs.jsr)
47+
testImplementation(libs.jna)
48+
}
49+
50+
tasks.test {
51+
dependsOn(
52+
":bson:jar",
53+
":bson-record-codec:jar",
54+
":mongodb-crypt:jar",
55+
":driver-core:jar",
56+
":bson-scala:jar",
57+
":driver-sync:jar",
58+
":driver-reactive-streams:jar",
59+
":driver-scala:jar",
60+
":driver-kotlin-sync:jar",
61+
":driver-kotlin-coroutine:jar",
62+
":driver-kotlin-extensions:jar"
63+
)
64+
systemProperty("projectRoot", rootProject.projectDir.absolutePath)
65+
}
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
/*
2+
* Copyright 2008-present MongoDB, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.mongodb.osgi;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
import static org.assertj.core.api.Assertions.fail;
20+
21+
import java.io.File;
22+
import java.io.IOException;
23+
import java.io.InputStream;
24+
import java.io.UncheckedIOException;
25+
import java.nio.file.Files;
26+
import java.nio.file.Path;
27+
import java.nio.file.Paths;
28+
import java.util.ArrayList;
29+
import java.util.Enumeration;
30+
import java.util.HashMap;
31+
import java.util.LinkedHashSet;
32+
import java.util.List;
33+
import java.util.Map;
34+
import java.util.Set;
35+
import java.util.jar.JarEntry;
36+
import java.util.jar.JarFile;
37+
import java.util.jar.Manifest;
38+
import java.util.stream.Collectors;
39+
import java.util.stream.Stream;
40+
41+
import org.apache.felix.framework.FrameworkFactory;
42+
import org.junit.jupiter.api.AfterEach;
43+
import org.junit.jupiter.api.BeforeEach;
44+
import org.junit.jupiter.api.Test;
45+
import org.junit.jupiter.api.io.TempDir;
46+
import org.osgi.framework.Bundle;
47+
import org.osgi.framework.BundleContext;
48+
import org.osgi.framework.BundleException;
49+
import org.osgi.framework.FrameworkEvent;
50+
import org.osgi.framework.launch.Framework;
51+
52+
class OsgiBundleResolutionTest {
53+
54+
private static final Path PROJECT_ROOT = Paths.get(System.getProperty("projectRoot", "../.."));
55+
56+
// Listed in dependency order (leaves last) so that the first bundle.start() failure
57+
// identifies the root cause rather than a cascading downstream resolution error.
58+
private static final String[] BUNDLE_MODULES = {
59+
"bson",
60+
"bson-record-codec",
61+
"mongodb-crypt",
62+
"driver-core",
63+
"bson-scala",
64+
"driver-sync",
65+
"driver-reactive-streams",
66+
"driver-scala",
67+
"driver-kotlin-sync",
68+
"driver-kotlin-coroutine",
69+
"driver-kotlin-extensions"
70+
};
71+
72+
// JARs on the test classpath whose packages are exported from the Felix system bundle,
73+
// satisfying non-optional imports from the bundles under test.
74+
private static final String[] SYSTEM_PACKAGE_JAR_PREFIXES = {
75+
"reactive-streams",
76+
"reactor-core",
77+
"kotlin-stdlib",
78+
"kotlin-reflect",
79+
"kotlinx-coroutines-core",
80+
"kotlinx-coroutines-reactive",
81+
"jsr305",
82+
"jna"
83+
};
84+
85+
// Eagerly computed — the classpath is fixed for the lifetime of the test JVM.
86+
private static final String SYSTEM_PACKAGES = buildSystemPackagesFromClasspath();
87+
88+
@TempDir
89+
private Path cacheDir;
90+
91+
private Framework framework;
92+
93+
@BeforeEach
94+
void startFramework() throws BundleException {
95+
Map<String, String> config = new HashMap<>();
96+
config.put("org.osgi.framework.storage", cacheDir.toString());
97+
config.put("org.osgi.framework.storage.clean", "onFirstInit");
98+
config.put("felix.log.level", "1");
99+
if (!SYSTEM_PACKAGES.isEmpty()) {
100+
config.put("org.osgi.framework.system.packages.extra", SYSTEM_PACKAGES);
101+
}
102+
103+
framework = new FrameworkFactory().newFramework(config);
104+
framework.start();
105+
}
106+
107+
@AfterEach
108+
void stopFramework() throws BundleException, InterruptedException {
109+
if (framework != null) {
110+
framework.stop();
111+
FrameworkEvent event = framework.waitForStop(10_000);
112+
if (event.getType() == FrameworkEvent.WAIT_TIMEDOUT) {
113+
throw new IllegalStateException("OSGi framework did not stop within 10 seconds");
114+
}
115+
}
116+
}
117+
118+
@Test
119+
void bundlesResolveWithoutOptionalDependencies() throws Exception {
120+
List<Bundle> installed = installAllBundles(framework.getBundleContext());
121+
122+
for (Bundle bundle : installed) {
123+
try {
124+
bundle.start();
125+
} catch (BundleException e) {
126+
// Fail immediately on the first resolution error. Bundles are wired by
127+
// Import-Package, so an unresolved bundle (e.g. driver-core missing a
128+
// required import) leaves its exported packages unsatisfied for all
129+
// downstream bundles. Collecting further failures would only add
130+
// cascading noise — the first message identifies the root cause.
131+
fail(formatBundleFailure(bundle, e));
132+
}
133+
}
134+
}
135+
136+
@Test
137+
void bundlesReportCorrectSymbolicNames() throws Exception {
138+
List<Bundle> installed = installAllBundles(framework.getBundleContext());
139+
140+
List<String> symbolicNames = installed.stream()
141+
.map(Bundle::getSymbolicName)
142+
.collect(Collectors.toList());
143+
144+
assertThat(symbolicNames).containsExactly(
145+
"org.mongodb.bson",
146+
"org.mongodb.bson-record-codec",
147+
"com.mongodb.crypt.capi",
148+
"org.mongodb.driver-core",
149+
"org.mongodb.scala.mongo-scala-bson",
150+
"org.mongodb.driver-sync",
151+
"org.mongodb.driver-reactivestreams",
152+
"org.mongodb.scala.mongo-scala-driver",
153+
"org.mongodb.mongodb-driver-kotlin-sync",
154+
"org.mongodb.mongodb-driver-kotlin-coroutine",
155+
"org.mongodb.mongodb-driver-kotlin-extensions");
156+
}
157+
158+
private List<Bundle> installAllBundles(final BundleContext ctx) throws Exception {
159+
List<Bundle> installed = new ArrayList<>();
160+
for (String module : BUNDLE_MODULES) {
161+
File jar = findBundleJar(module);
162+
try (InputStream is = Files.newInputStream(jar.toPath())) {
163+
Bundle bundle = ctx.installBundle("file:" + jar.getAbsolutePath(), is);
164+
installed.add(bundle);
165+
}
166+
}
167+
return installed;
168+
}
169+
170+
// Parses Felix's error message format to extract the missing package name.
171+
private static String formatBundleFailure(final Bundle bundle, final BundleException e) {
172+
String msg = e.getMessage();
173+
StringBuilder sb = new StringBuilder();
174+
sb.append("\n\n====================================================================\n");
175+
sb.append("BUNDLE RESOLUTION FAILURE: ").append(bundle.getSymbolicName()).append("\n");
176+
sb.append("====================================================================\n");
177+
178+
if (msg != null && msg.contains("missing requirement")) {
179+
int pkgStart = msg.indexOf("osgi.wiring.package=");
180+
if (pkgStart >= 0) {
181+
String remainder = msg.substring(pkgStart + "osgi.wiring.package=".length());
182+
int pkgEnd = remainder.indexOf(')');
183+
String missingPackage = pkgEnd >= 0 ? remainder.substring(0, pkgEnd) : remainder;
184+
sb.append("Missing required package: ").append(missingPackage).append("\n\n");
185+
sb.append("FIX: Add '").append(missingPackage).append(".*;resolution:=optional' to the\n");
186+
sb.append(" Import-Package list in the module's build.gradle.kts\n");
187+
}
188+
}
189+
190+
sb.append("\nFull error: ").append(msg);
191+
sb.append("\n====================================================================\n");
192+
return sb.toString();
193+
}
194+
195+
private static String buildSystemPackagesFromClasspath() {
196+
Set<String> packages = new LinkedHashSet<>();
197+
String classpath = System.getProperty("java.class.path", "");
198+
199+
for (String entry : classpath.split(File.pathSeparator)) {
200+
File file = new File(entry);
201+
String name = file.getName();
202+
if (!matchesAnyPrefix(name)) {
203+
continue;
204+
}
205+
if (!file.isFile() || !name.endsWith(".jar")) {
206+
continue;
207+
}
208+
try (JarFile jar = new JarFile(file)) {
209+
Manifest manifest = jar.getManifest();
210+
if (manifest == null) {
211+
continue;
212+
}
213+
String version = manifest.getMainAttributes().getValue("Bundle-Version");
214+
if (version == null) {
215+
version = "0.0.0";
216+
}
217+
Enumeration<JarEntry> entries = jar.entries();
218+
while (entries.hasMoreElements()) {
219+
JarEntry jarEntry = entries.nextElement();
220+
String entryName = jarEntry.getName();
221+
if (entryName.endsWith(".class") && entryName.contains("/")) {
222+
String pkg = entryName.substring(0, entryName.lastIndexOf('/')).replace('/', '.');
223+
packages.add(pkg + ";version=\"" + version + "\"");
224+
}
225+
}
226+
} catch (IOException e) {
227+
throw new UncheckedIOException("Failed to read classpath JAR: " + file, e);
228+
}
229+
}
230+
231+
return String.join(",", packages);
232+
}
233+
234+
private static boolean matchesAnyPrefix(final String fileName) {
235+
for (String prefix : SYSTEM_PACKAGE_JAR_PREFIXES) {
236+
if (fileName.startsWith(prefix)) {
237+
return true;
238+
}
239+
}
240+
return false;
241+
}
242+
243+
private static File findBundleJar(final String module) {
244+
Path libsDir = PROJECT_ROOT.resolve(module).resolve("build").resolve("libs");
245+
assertThat(libsDir)
246+
.as("Build output directory for module '%s' must exist. Run ./gradlew jar first.", module)
247+
.isDirectory();
248+
249+
try (Stream<Path> files = Files.list(libsDir)) {
250+
List<File> candidates = files
251+
.filter(p -> p.getFileName().toString().endsWith(".jar"))
252+
.filter(p -> !p.getFileName().toString().contains("-test"))
253+
.filter(p -> !p.getFileName().toString().contains("-sources"))
254+
.filter(p -> !p.getFileName().toString().contains("-javadoc"))
255+
.map(Path::toFile)
256+
.collect(Collectors.toList());
257+
258+
assertThat(candidates)
259+
.as("Expected exactly one main JAR in %s", libsDir)
260+
.hasSize(1);
261+
262+
return candidates.get(0);
263+
} catch (IOException e) {
264+
return fail("Failed to list JARs in " + libsDir + ": " + e.getMessage());
265+
}
266+
}
267+
}

0 commit comments

Comments
 (0)