This is an automated email from the ASF dual-hosted git repository.
lhotari pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pulsar.git
The following commit(s) were added to refs/heads/master by this push:
new f0d2bcc7a78 [feat][client] Embed GraalVM native image config (#25883)
f0d2bcc7a78 is described below
commit f0d2bcc7a78148765bf1549beca4db03663a6754
Author: David Kjerrumgaard <[email protected]>
AuthorDate: Mon Jun 8 17:42:44 2026 +0200
[feat][client] Embed GraalVM native image config (#25883)
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Lari Hotari <[email protected]>
Co-authored-by: Lari Hotari <[email protected]>
---
.github/workflows/pulsar-ci.yaml | 17 +
gradle/libs.versions.toml | 2 +
pulsar-build/run_integration_group_gradle.sh | 12 +
pulsar-build/run_unit_group_gradle.sh | 1 +
.../native-image.properties | 27 +
.../reflect-config.json | 770 +++++++++++++++++++++
.../resource-config.json | 17 +
.../client/admin/NativeImageConfigAdminTest.java | 183 +++++
.../pulsar-client-original/native-image.properties | 27 +
.../pulsar-client-original/reflect-config.json | 108 +++
.../pulsar-client-original/resource-config.json | 12 +
.../pulsar/client/impl/NativeImageConfigTest.java | 181 +++++
settings.gradle.kts | 2 +
tests/pulsar-client-native-image/build.gradle.kts | 65 ++
.../nativeimage/NativeImageTesterApp.java | 143 ++++
.../integration/nativeimage/package-info.java | 25 +
.../nativeimage/NativeImageSmokeTest.java | 197 ++++++
.../integration/nativeimage/PulsarContainer.java | 60 ++
.../src/test/resources/native-image-tests.xml | 28 +
19 files changed, 1877 insertions(+)
diff --git a/.github/workflows/pulsar-ci.yaml b/.github/workflows/pulsar-ci.yaml
index 2dd2cc25d53..17d88719689 100644
--- a/.github/workflows/pulsar-ci.yaml
+++ b/.github/workflows/pulsar-ci.yaml
@@ -511,6 +511,13 @@ jobs:
- name: Kubernetes
group: PULSAR_K8S
+ - name: Native Image
+ group: NATIVE_IMAGE
+ runtime_jdk: 25
+ # Native image compilation (even in quick build mode) is slower
than a
+ # regular test group, so allow extra headroom over the default
timeout.
+ timeout: 40
+
steps:
- name: checkout
uses: actions/checkout@v6
@@ -526,6 +533,7 @@ jobs:
limit-access-to-actor: true
- name: Set up JDK ${{ env.CI_JDK_MAJOR_VERSION }}${{ matrix.runtime_jdk
&& matrix.runtime_jdk != env.CI_JDK_MAJOR_VERSION && format(' and {0}',
matrix.runtime_jdk) || '' }}
+ if: ${{ matrix.group != 'NATIVE_IMAGE' }}
uses: actions/setup-java@v5
with:
distribution: ${{ env.JDK_DISTRIBUTION }}
@@ -533,6 +541,15 @@ jobs:
${{ matrix.runtime_jdk != env.CI_JDK_MAJOR_VERSION &&
matrix.runtime_jdk || '' }}
${{ env.CI_JDK_MAJOR_VERSION }}
+ # The NATIVE_IMAGE group compiles a native binary with GraalVM
native-build-tools,
+ # which requires a GraalVM JDK. This overrides JAVA_HOME for that matrix
entry only.
+ - name: Set up GraalVM
+ if: ${{ matrix.group == 'NATIVE_IMAGE' }}
+ uses: graalvm/setup-graalvm@329c42c5f4c343bceb505f0b28cc8499bc2bf174 #
v1.5.4
+ with:
+ distribution: 'graalvm-community'
+ java-version: 25 # always use GraalVM 25.x
+
- name: Setup Gradle
uses: ./.github/actions/setup-gradle
with:
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a8a1043ac83..77337521a15 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -145,6 +145,7 @@ oxia = "0.8.0"
oxia-testcontainers = "0.7.4"
# Build plugins
lightproto = "0.7.3"
+graalvm-buildtools = "1.1.1"
errorprone = "2.45.0"
spotbugs = "4.9.6"
checkerframework = "3.33.0"
@@ -472,6 +473,7 @@ datasketches-java = { module =
"org.apache.datasketches:datasketches-java", vers
[plugins]
lightproto = { id = "io.streamnative.lightproto", version.ref = "lightproto" }
+graalvm-native = { id = "org.graalvm.buildtools.native", version.ref =
"graalvm-buildtools" }
nar = "io.github.merlimat.nar:0.1.3"
protobuf = "com.google.protobuf:0.10.0"
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
diff --git a/pulsar-build/run_integration_group_gradle.sh
b/pulsar-build/run_integration_group_gradle.sh
index 8ab24b08936..fe6d7a50333 100755
--- a/pulsar-build/run_integration_group_gradle.sh
+++ b/pulsar-build/run_integration_group_gradle.sh
@@ -146,6 +146,18 @@ test_group_shade_run() {
"$SCRIPT_DIR/pulsar_ci_tool.sh" move_test_reports
}
+test_group_native_image() {
+ echo "::group::Run GraalVM native image smoke tests"
+ # Compiles NativeImageTesterApp to a native binary (using the embedded
+ # META-INF/native-image reachability metadata) and runs the produce/consume
+ # smoke test that drives the binary via ProcessBuilder. Requires a GraalVM
JDK.
+ ./gradlew --no-configuration-cache \
+ :tests:pulsar-client-native-image:test \
+ "$@"
+ echo "::endgroup::"
+ "$SCRIPT_DIR/pulsar_ci_tool.sh" move_test_reports
+}
+
list_test_groups() {
declare -F | awk '{print $NF}' | sort | grep -E '^test_group_' | sed
's/^test_group_//g' | tr '[:lower:]' '[:upper:]'
}
diff --git a/pulsar-build/run_unit_group_gradle.sh
b/pulsar-build/run_unit_group_gradle.sh
index e7bdae740d6..f366cfd7d3f 100755
--- a/pulsar-build/run_unit_group_gradle.sh
+++ b/pulsar-build/run_unit_group_gradle.sh
@@ -140,6 +140,7 @@ function test_group_other() {
-x :tests:pulsar-client-admin-shade-test:test \
-x :tests:pulsar-client-all-shade-test:test \
-x :tests:pulsar-client-shade-test:test \
+ -x :tests:pulsar-client-native-image:test \
test
# Run DnsResolverTest separately since it relies on static field values
diff --git
a/pulsar-client-admin/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-admin-original/native-image.properties
b/pulsar-client-admin/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-admin-original/native-image.properties
new file mode 100644
index 00000000000..657676e965a
--- /dev/null
+++
b/pulsar-client-admin/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-admin-original/native-image.properties
@@ -0,0 +1,27 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+#
+# GraalVM native-image configuration for the Pulsar admin client
+# (pulsar-client-admin-original). This complements the configuration embedded
in
+# pulsar-client-original, which is pulled in transitively.
+#
+# The admin client builds a Jersey JAX-RS client backed by
AsyncHttpClient/Netty.
+# The connector holds Netty-backed resources that must be initialized at run
time
+# rather than baked into the image heap at build time.
+Args = --initialize-at-run-time=\
+ org.apache.pulsar.client.admin.internal.http.AsyncHttpConnector
diff --git
a/pulsar-client-admin/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-admin-original/reflect-config.json
b/pulsar-client-admin/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-admin-original/reflect-config.json
new file mode 100644
index 00000000000..b13884b5ebc
--- /dev/null
+++
b/pulsar-client-admin/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-admin-original/reflect-config.json
@@ -0,0 +1,770 @@
+[
+ {
+ "name": "org.apache.pulsar.client.admin.internal.http.AsyncHttpConnector",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.client.admin.internal.http.AsyncHttpConnectorProvider",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.admin.internal.JacksonConfigurator",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.admin.internal.OffloadProcessStatusImpl",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.conf.InternalConfigurationData",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.functions.AuthenticationConfig",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.functions.ConsumerConfig",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.functions.CryptoConfig",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.functions.FunctionConfig",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.functions.FunctionDefinition",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.functions.FunctionState",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.functions.ProducerConfig",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.functions.Resources",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.functions.UpdateOptions",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.functions.WindowConfig",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.functions.WorkerInfo",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.io.BatchSourceConfig",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.io.ConnectorDefinition",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.io.SinkConfig",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.io.SourceConfig",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.naming.TopicDomain",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.partition.PartitionedTopicMetadata",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.AutoFailoverPolicy",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.AuthAction",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.AuthPolicies",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.AutoFailoverPolicyData",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.AutoFailoverPolicyType",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.AutoSubscriptionCreationOverride",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.AutoTopicCreationOverride",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.BacklogQuota",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.BookieAffinityGroupData",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.BookieInfo",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.BookiesClusterInfo",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.BookiesRackConfiguration",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.BrokerAssignment",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.BrokerInfo",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.BrokerNamespaceIsolationData",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.BrokerStatus",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.BundlesData",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.ClusterData",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.ClusterPolicies",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.CompactionStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.ConsumerStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.DelayedDeliveryPolicies",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.DispatchRate",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.DrainingHash",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.EntryFilters",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.ErrorData",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.ExceptionInformation",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.FailureDomain",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.FunctionInstanceStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.FunctionInstanceStatsData",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.FunctionInstanceStatsDataBase",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.FunctionStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.FunctionStatus",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.impl.AutoSubscriptionCreationOverrideImpl",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.impl.AutoTopicCreationOverrideImpl",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.impl.BacklogQuotaImpl",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.impl.BookieAffinityGroupDataImpl",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.impl.BookieInfoImpl",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.impl.BookiesClusterInfoImpl",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.impl.BrokerInfoImpl",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.impl.BrokerStatusImpl",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.impl.BundlesDataImpl",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.impl.DelayedDeliveryPoliciesImpl",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.impl.DispatchRateImpl",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.InactiveTopicDeleteMode",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.InactiveTopicPolicies",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.ManagedLedgerInternalStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.NamespaceIsolationData",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.NamespaceIsolationPolicyUnloadScope",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.NamespaceOwnershipStatus",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.NonPersistentPartitionedTopicStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.NonPersistentPublisherStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.NonPersistentReplicatorStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.NonPersistentSubscriptionStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.NonPersistentTopicStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.OffloadedReadPriority",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.OffloadPolicies",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.PartitionedTopicInternalStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.PartitionedTopicStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.PersistencePolicies",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.PersistentTopicInternalStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.Policies",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.PublisherStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.PublishRate",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.RawBookieInfo",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.ReplicatorStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.ResourceGroup",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.ResourceQuota",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.RetentionPolicies",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.SchemaAutoUpdateCompatibilityStrategy",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.SchemaCompatibilityStrategy",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.SchemaMetadata",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.SegmentsStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.SegmentStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.SinkStatus",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.SnapshotSystemTopicInternalStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.SourceStatus",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.SubscribeRate",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.SubscriptionAuthMode",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.SubscriptionStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.TenantInfo",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.TopicHashPositions",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.TopicStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.TopicType",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.TransactionBufferInternalStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.TransactionBufferStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.TransactionCoordinatorInfo",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.TransactionCoordinatorInternalStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.TransactionCoordinatorStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.TransactionCoordinatorStatus",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.TransactionInBufferStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.TransactionInPendingAckStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.TransactionLogStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.TransactionMetadata",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.TransactionPendingAckInternalStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.TransactionPendingAckStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.data.ValidateResult",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.policies.data.WorkerFunctionInstanceStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.impl.AutoFailoverPolicyFactory",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.policies.impl.MinAvailablePolicy",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.protocol.schema.IsCompatibilityResponse",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.protocol.schema.PostSchemaPayload",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.stats.AllocatorStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.stats.AnalyzeSubscriptionBacklogResult",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.stats.Metrics",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.stats.PositionInPendingAckStats",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ }
+]
diff --git
a/pulsar-client-admin/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-admin-original/resource-config.json
b/pulsar-client-admin/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-admin-original/resource-config.json
new file mode 100644
index 00000000000..b726011b12c
--- /dev/null
+++
b/pulsar-client-admin/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-admin-original/resource-config.json
@@ -0,0 +1,17 @@
+{
+ "resources": {
+ "includes": [
+ {
+ "comment": "AsyncHttpClient configuration loaded by the admin REST
connector.",
+ "pattern": "\\Qorg/asynchttpclient/config/ahc-default.properties\\E"
+ },
+ {
+ "pattern": "\\Qorg/asynchttpclient/config/ahc.properties\\E"
+ },
+ {
+ "comment": "Jersey localization bundles loaded reflectively at
runtime.",
+ "pattern": "org/glassfish/jersey/.*\\.properties"
+ }
+ ]
+ }
+}
diff --git
a/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/NativeImageConfigAdminTest.java
b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/NativeImageConfigAdminTest.java
new file mode 100644
index 00000000000..fdc46395dc0
--- /dev/null
+++
b/pulsar-client-admin/src/test/java/org/apache/pulsar/client/admin/NativeImageConfigAdminTest.java
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.pulsar.client.admin;
+
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.fail;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParser;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.Properties;
+import java.util.Set;
+import org.testng.annotations.Test;
+
+/**
+ * Validates the GraalVM native-image configuration files embedded under
+ * {@code
META-INF/native-image/org.apache.pulsar/pulsar-client-admin-original/}.
+ *
+ * <p>The admin client serializes a large set of model classes in
+ * {@code org.apache.pulsar.common.*} over its REST API via Jackson, so each
of those classes
+ * must be registered for reflection. These tests catch stale or missing
entries early — for
+ * example, a model class renamed or removed in a future release would cause
+ * {@link #reflectConfigClassesExist} to fail, and removing model coverage
would cause
+ * {@link #representativeModelClassesRegistered} to fail.
+ *
+ * <p>Note: this validates the Pulsar-owned metadata only. Library-level
metadata for Jersey /
+ * HK2 internals (if any is still required) is exercised end-to-end by the
native-image smoke
+ * test in the {@code pulsar-client-native-image} module.
+ */
+public class NativeImageConfigAdminTest {
+
+ private static final String CONFIG_BASE =
+
"META-INF/native-image/org.apache.pulsar/pulsar-client-admin-original/";
+
+ // ---- reflect-config.json ----
+
+ @Test
+ public void reflectConfigIsValidJson() throws IOException {
+ try (InputStream is = openResource("reflect-config.json")) {
+ JsonArray array = JsonParser.parseReader(
+ new InputStreamReader(is,
StandardCharsets.UTF_8)).getAsJsonArray();
+ assertFalse(array.isEmpty(), "reflect-config.json must not be
empty");
+ }
+ }
+
+ @Test
+ public void reflectConfigClassesExist() throws IOException {
+ Set<String> classNames = parseReflectConfigClassNames();
+ assertFalse(classNames.isEmpty());
+ for (String className : classNames) {
+ try {
+ Class.forName(className, false, getClass().getClassLoader());
+ } catch (ClassNotFoundException e) {
+ fail("reflect-config.json references a class that does not
exist on the "
+ + "classpath: " + className + ". If this class was
renamed or removed, "
+ + "update the native-image configuration.");
+ }
+ }
+ }
+
+ @Test
+ public void representativeModelClassesRegistered() throws IOException {
+ Set<String> registered = parseReflectConfigClassNames();
+
+ // A representative sample of the policy/data model classes that
travel over the
+ // admin REST API as JSON. If any of these is dropped from the config,
Jackson would
+ // be unable to (de)serialize it in a native image. This is a guard
against silently
+ // losing model coverage — if you intentionally remove one, update
this list too.
+ String[] expectedModelClasses = {
+ "org.apache.pulsar.common.policies.data.Policies",
+ "org.apache.pulsar.common.policies.data.TenantInfo",
+ "org.apache.pulsar.common.policies.data.TopicStats",
+ "org.apache.pulsar.common.policies.data.PartitionedTopicStats",
+ "org.apache.pulsar.common.policies.data.BacklogQuota",
+ "org.apache.pulsar.common.policies.data.RetentionPolicies",
+ "org.apache.pulsar.common.policies.data.impl.BacklogQuotaImpl",
+ "org.apache.pulsar.common.policies.data.impl.BundlesDataImpl",
+ "org.apache.pulsar.common.functions.FunctionConfig",
+ "org.apache.pulsar.common.io.SinkConfig",
+ "org.apache.pulsar.common.io.SourceConfig",
+ };
+
+ for (String modelClass : expectedModelClasses) {
+ assertTrue(registered.contains(modelClass),
+ "Model class " + modelClass + " is not registered in
reflect-config.json. "
+ + "Native-image builds will fail to (de)serialize
it over the admin "
+ + "REST API.");
+ }
+ }
+
+ // ---- native-image.properties ----
+
+ @Test
+ public void runtimeInitializedClassesExist() throws IOException {
+ Set<String> classNames = parseRuntimeInitializedClassNames();
+ assertFalse(classNames.isEmpty());
+ for (String className : classNames) {
+ try {
+ Class.forName(className, false, getClass().getClassLoader());
+ } catch (ClassNotFoundException e) {
+ fail("native-image.properties references a runtime-initialized
class that "
+ + "does not exist on the classpath: " + className
+ + ". If this class was renamed or removed, update the "
+ + "native-image configuration.");
+ }
+ }
+ }
+
+ // ---- resource-config.json ----
+
+ @Test
+ public void resourceConfigIsValidJson() throws IOException {
+ try (InputStream is = openResource("resource-config.json")) {
+ JsonParser.parseReader(
+ new InputStreamReader(is,
StandardCharsets.UTF_8)).getAsJsonObject();
+ }
+ }
+
+ // ---- helpers ----
+
+ private InputStream openResource(String fileName) {
+ InputStream is =
getClass().getClassLoader().getResourceAsStream(CONFIG_BASE + fileName);
+ assertNotNull(is, CONFIG_BASE + fileName + " not found on classpath");
+ return is;
+ }
+
+ private Set<String> parseReflectConfigClassNames() throws IOException {
+ Set<String> names = new HashSet<>();
+ try (InputStream is = openResource("reflect-config.json")) {
+ JsonArray array = JsonParser.parseReader(
+ new InputStreamReader(is,
StandardCharsets.UTF_8)).getAsJsonArray();
+ for (JsonElement element : array) {
+ String name =
element.getAsJsonObject().get("name").getAsString();
+ names.add(name);
+ }
+ }
+ return names;
+ }
+
+ private Set<String> parseRuntimeInitializedClassNames() throws IOException
{
+ Set<String> names = new HashSet<>();
+ try (InputStream is = openResource("native-image.properties")) {
+ Properties props = new Properties();
+ props.load(is);
+ String args = props.getProperty("Args", "");
+ // Extract class names from
--initialize-at-run-time=com.foo.A,com.foo.B,...
+ for (String arg : args.split("\\s+")) {
+ if (arg.startsWith("--initialize-at-run-time=")) {
+ String value =
arg.substring("--initialize-at-run-time=".length());
+ for (String cls : value.split(",")) {
+ String trimmed = cls.trim().replaceAll("[\\\\,]", "");
+ if (!trimmed.isEmpty()) {
+ names.add(trimmed);
+ }
+ }
+ }
+ }
+ }
+ return names;
+ }
+}
diff --git
a/pulsar-client/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-original/native-image.properties
b/pulsar-client/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-original/native-image.properties
new file mode 100644
index 00000000000..3899181d69f
--- /dev/null
+++
b/pulsar-client/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-original/native-image.properties
@@ -0,0 +1,27 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+#
+Args = --initialize-at-run-time=\
+ org.apache.pulsar.common.allocator.PulsarByteBufAllocator,\
+ org.apache.pulsar.common.protocol.Commands,\
+ org.apache.pulsar.common.util.Backoff,\
+ org.apache.pulsar.client.impl.auth.oauth2.protocol.TokenClient,\
+ org.apache.pulsar.client.impl.schema.generic.GenericProtobufNativeSchema,\
+ org.apache.pulsar.client.impl.ConnectionPool,\
+ org.apache.pulsar.client.impl.ControlledClusterFailover,\
+ org.apache.pulsar.client.impl.HttpClient
diff --git
a/pulsar-client/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-original/reflect-config.json
b/pulsar-client/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-original/reflect-config.json
new file mode 100644
index 00000000000..384b7b05dac
--- /dev/null
+++
b/pulsar-client/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-original/reflect-config.json
@@ -0,0 +1,108 @@
+[
+ {
+ "name": "org.apache.pulsar.client.impl.conf.ClientConfigurationData",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.impl.conf.ProducerConfigurationData",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.impl.conf.ConsumerConfigurationData",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.impl.auth.oauth2.KeyFile",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.impl.auth.oauth2.protocol.Metadata",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.impl.auth.oauth2.protocol.TokenResult",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.impl.auth.oauth2.protocol.TokenError",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.client.impl.auth.oauth2.protocol.ClientCredentialsExchangeRequest",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.client.impl.schema.ProtobufNativeSchema$ProtoBufParsingInfo",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.client.impl.schema.ProtobufSchema$ProtoBufParsingInfo",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name":
"org.apache.pulsar.common.protocol.schema.ProtobufNativeSchemaData",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.common.schema.KeyValue",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.api.url.DataURLStreamHandler",
+ "allDeclaredFields": true,
+ "allDeclaredMethods": true,
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.util.SecretsSerializer",
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.impl.auth.AuthenticationDisabled",
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.impl.auth.AuthenticationTls",
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.impl.auth.AuthenticationToken",
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.impl.auth.AuthenticationBasic",
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.impl.auth.AuthenticationKeyStoreTls",
+ "allDeclaredConstructors": true
+ },
+ {
+ "name": "org.apache.pulsar.client.impl.auth.oauth2.AuthenticationOAuth2",
+ "allDeclaredConstructors": true
+ }
+]
diff --git
a/pulsar-client/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-original/resource-config.json
b/pulsar-client/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-original/resource-config.json
new file mode 100644
index 00000000000..99b2906b591
--- /dev/null
+++
b/pulsar-client/src/main/resources/META-INF/native-image/org.apache.pulsar/pulsar-client-original/resource-config.json
@@ -0,0 +1,12 @@
+{
+ "resources": {
+ "includes": [
+ {
+ "pattern": "\\Qorg/asynchttpclient/config/ahc-default.properties\\E"
+ },
+ {
+ "pattern": "\\Qorg/asynchttpclient/config/ahc.properties\\E"
+ }
+ ]
+ }
+}
diff --git
a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/NativeImageConfigTest.java
b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/NativeImageConfigTest.java
new file mode 100644
index 00000000000..fd648c837e0
--- /dev/null
+++
b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/NativeImageConfigTest.java
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.pulsar.client.impl;
+
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.fail;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParser;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.Properties;
+import java.util.Set;
+import org.apache.pulsar.client.api.Authentication;
+import org.apache.pulsar.client.impl.auth.AuthenticationBasic;
+import org.apache.pulsar.client.impl.auth.AuthenticationDisabled;
+import org.apache.pulsar.client.impl.auth.AuthenticationKeyStoreTls;
+import org.apache.pulsar.client.impl.auth.AuthenticationTls;
+import org.apache.pulsar.client.impl.auth.AuthenticationToken;
+import org.apache.pulsar.client.impl.auth.oauth2.AuthenticationOAuth2;
+import org.testng.annotations.Test;
+
+/**
+ * Validates the GraalVM native-image configuration files embedded under
+ * {@code META-INF/native-image/org.apache.pulsar/pulsar-client-original/}.
+ *
+ * <p>These tests catch stale or missing entries early — for example, a class
+ * renamed or removed in a future release would cause {@link
#reflectConfigClassesExist}
+ * to fail, and a new {@link Authentication} implementation added without
updating
+ * the config would cause {@link #allAuthenticationPluginsRegistered} to fail.
+ */
+public class NativeImageConfigTest {
+
+ private static final String CONFIG_BASE =
+ "META-INF/native-image/org.apache.pulsar/pulsar-client-original/";
+
+ // ---- reflect-config.json ----
+
+ @Test
+ public void reflectConfigIsValidJson() throws IOException {
+ try (InputStream is = openResource("reflect-config.json")) {
+ JsonArray array = JsonParser.parseReader(
+ new InputStreamReader(is,
StandardCharsets.UTF_8)).getAsJsonArray();
+ assertFalse(array.isEmpty(), "reflect-config.json must not be
empty");
+ }
+ }
+
+ @Test
+ public void reflectConfigClassesExist() throws IOException {
+ Set<String> classNames = parseReflectConfigClassNames();
+ assertFalse(classNames.isEmpty());
+ for (String className : classNames) {
+ try {
+ Class.forName(className, false, getClass().getClassLoader());
+ } catch (ClassNotFoundException e) {
+ fail("reflect-config.json references a class that does not
exist on the "
+ + "classpath: " + className + ". If this class was
renamed or removed, "
+ + "update the native-image configuration.");
+ }
+ }
+ }
+
+ @Test
+ public void allAuthenticationPluginsRegistered() throws IOException {
+ Set<String> registered = parseReflectConfigClassNames();
+
+ // Every Authentication implementation shipped with
pulsar-client-original
+ // must be registered for reflective constructor access so that
+ // AuthenticationUtil.create() works in a native image.
+ //
+ // If you add a new Authentication implementation, add it here AND in
+ // reflect-config.json.
+ Class<?>[] expectedAuthPlugins = {
+ AuthenticationDisabled.class,
+ AuthenticationTls.class,
+ AuthenticationToken.class,
+ AuthenticationBasic.class,
+ AuthenticationKeyStoreTls.class,
+ AuthenticationOAuth2.class,
+ };
+
+ for (Class<?> authClass : expectedAuthPlugins) {
+ assertTrue(registered.contains(authClass.getName()),
+ "Authentication implementation " + authClass.getName()
+ + " is not registered in reflect-config.json.
Native-image builds "
+ + "will fail to instantiate this plugin via
reflection.");
+ }
+ }
+
+ // ---- native-image.properties ----
+
+ @Test
+ public void runtimeInitializedClassesExist() throws IOException {
+ Set<String> classNames = parseRuntimeInitializedClassNames();
+ assertFalse(classNames.isEmpty());
+ for (String className : classNames) {
+ try {
+ Class.forName(className, false, getClass().getClassLoader());
+ } catch (ClassNotFoundException e) {
+ fail("native-image.properties references a runtime-initialized
class that "
+ + "does not exist on the classpath: " + className
+ + ". If this class was renamed or removed, update the "
+ + "native-image configuration.");
+ }
+ }
+ }
+
+ // ---- resource-config.json ----
+
+ @Test
+ public void resourceConfigIsValidJson() throws IOException {
+ try (InputStream is = openResource("resource-config.json")) {
+ JsonParser.parseReader(
+ new InputStreamReader(is,
StandardCharsets.UTF_8)).getAsJsonObject();
+ }
+ }
+
+ // ---- helpers ----
+
+ private InputStream openResource(String fileName) {
+ InputStream is =
getClass().getClassLoader().getResourceAsStream(CONFIG_BASE + fileName);
+ assertNotNull(is, CONFIG_BASE + fileName + " not found on classpath");
+ return is;
+ }
+
+ private Set<String> parseReflectConfigClassNames() throws IOException {
+ Set<String> names = new HashSet<>();
+ try (InputStream is = openResource("reflect-config.json")) {
+ JsonArray array = JsonParser.parseReader(
+ new InputStreamReader(is,
StandardCharsets.UTF_8)).getAsJsonArray();
+ for (JsonElement element : array) {
+ String name =
element.getAsJsonObject().get("name").getAsString();
+ names.add(name);
+ }
+ }
+ return names;
+ }
+
+ private Set<String> parseRuntimeInitializedClassNames() throws IOException
{
+ Set<String> names = new HashSet<>();
+ try (InputStream is = openResource("native-image.properties")) {
+ Properties props = new Properties();
+ props.load(is);
+ String args = props.getProperty("Args", "");
+ // Extract class names from
--initialize-at-run-time=com.foo.A,com.foo.B,...
+ for (String arg : args.split("\\s+")) {
+ if (arg.startsWith("--initialize-at-run-time=")) {
+ String value =
arg.substring("--initialize-at-run-time=".length());
+ for (String cls : value.split(",")) {
+ String trimmed = cls.trim().replaceAll("[\\\\,]", "");
+ if (!trimmed.isEmpty()) {
+ names.add(trimmed);
+ }
+ }
+ }
+ }
+ }
+ return names;
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index f8f16f38603..5a75fbc68b3 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -267,3 +267,5 @@ include("tests:pulsar-client-admin-shade-test")
project(":tests:pulsar-client-admin-shade-test").projectDir =
file("tests/pulsar-client-admin-shade-test")
include("tests:pulsar-client-all-shade-test")
project(":tests:pulsar-client-all-shade-test").projectDir =
file("tests/pulsar-client-all-shade-test")
+include("tests:pulsar-client-native-image")
+project(":tests:pulsar-client-native-image").projectDir =
file("tests/pulsar-client-native-image")
diff --git a/tests/pulsar-client-native-image/build.gradle.kts
b/tests/pulsar-client-native-image/build.gradle.kts
new file mode 100644
index 00000000000..0296e87df0c
--- /dev/null
+++ b/tests/pulsar-client-native-image/build.gradle.kts
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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("pulsar.java-conventions")
+ alias(libs.plugins.graalvm.native)
+}
+
+// The CLI application (NativeImageTesterApp) is compiled to a native binary
by the
+// GraalVM native-build-tools plugin. The reachability metadata
(reflect-config.json,
+// resource-config.json, native-image.properties) is consumed automatically
from the
+// META-INF/native-image directories embedded in pulsar-client-original and
+// pulsar-client-admin-original. The TestNG smoke test then runs on the JVM
and drives
+// the native binary via java.lang.ProcessBuilder against a Testcontainers
Pulsar broker.
+
+dependencies {
+ // Use the "-original" (unshaded) clients: the embedded native-image
config references
+ // unshaded class names, so it only applies when compiling against these
artifacts.
+ implementation(project(":pulsar-client-original"))
+ implementation(project(":pulsar-client-admin-original"))
+
+ testImplementation(project(":buildtools"))
+ testImplementation(libs.testcontainers)
+}
+
+val nativeImageName = "pulsar-client-native-tester"
+
+graalvmNative {
+ // Quick build mode keeps CI native-image compilation under the
integration-test budget.
+ // See
https://www.graalvm.org/latest/reference-manual/native-image/overview/BuildOutput/
+ binaries.all {
+ quickBuild = true
+ }
+ binaries.named("main") {
+ imageName = nativeImageName
+ mainClass =
"org.apache.pulsar.tests.integration.nativeimage.NativeImageTesterApp"
+ buildArgs.add("--no-fallback")
+ }
+}
+
+tasks.named<Test>("test") {
+ useTestNG {
+ suiteXmlFiles =
listOf(file("src/test/resources/native-image-tests.xml"))
+ }
+ // The smoke test shells out to the compiled native binary, so build it
first.
+ dependsOn(tasks.named("nativeCompile"))
+ val nativeBinary =
layout.buildDirectory.file("native/nativeCompile/$nativeImageName")
+ systemProperty("native.image.binary",
nativeBinary.get().asFile.absolutePath)
+}
diff --git
a/tests/pulsar-client-native-image/src/main/java/org/apache/pulsar/tests/integration/nativeimage/NativeImageTesterApp.java
b/tests/pulsar-client-native-image/src/main/java/org/apache/pulsar/tests/integration/nativeimage/NativeImageTesterApp.java
new file mode 100644
index 00000000000..759e79bc022
--- /dev/null
+++
b/tests/pulsar-client-native-image/src/main/java/org/apache/pulsar/tests/integration/nativeimage/NativeImageTesterApp.java
@@ -0,0 +1,143 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.pulsar.tests.integration.nativeimage;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.apache.pulsar.client.admin.PulsarAdmin;
+import org.apache.pulsar.client.api.Consumer;
+import org.apache.pulsar.client.api.Message;
+import org.apache.pulsar.client.api.Producer;
+import org.apache.pulsar.client.api.PulsarClient;
+import org.apache.pulsar.client.api.Schema;
+import org.apache.pulsar.client.api.SubscriptionType;
+
+/**
+ * Standalone application compiled to a GraalVM native image by the
+ * {@code pulsar-client-native-image} test module. It exercises the Pulsar
client (and
+ * admin client) against a running broker so the {@code NativeImageSmokeTest}
can verify
+ * that the native image — built using only the metadata embedded in
+ * {@code pulsar-client-original} and {@code pulsar-client-admin-original} —
actually works
+ * at runtime.
+ *
+ * <p>Each test case is selected through a CLI subcommand. The application
prints a stable
+ * marker ({@link #SUCCESS_MARKER} / {@link #FAILURE_MARKER}) to stdout and
uses the process
+ * exit code (0 = success, non-zero = failure) so the test can assert on both.
+ *
+ * <p>Usage:
+ * <pre>
+ * pulsar-client-native-tester produce-consume --service-url
pulsar://host:6650 [--topic name]
+ * pulsar-client-native-tester admin --admin-url http://host:8080
+ * </pre>
+ */
+public final class NativeImageTesterApp {
+
+ /** Printed to stdout when a subcommand completes successfully. */
+ static final String SUCCESS_MARKER = "NATIVE_IMAGE_TEST_SUCCESS";
+ /** Printed to stdout (prefixing the error) when a subcommand fails. */
+ static final String FAILURE_MARKER = "NATIVE_IMAGE_TEST_FAILURE";
+
+ private NativeImageTesterApp() {
+ }
+
+ public static void main(String[] args) {
+ try {
+ if (args.length == 0) {
+ throw new IllegalArgumentException("missing subcommand;
expected 'produce-consume' or 'admin'");
+ }
+ final String subcommand = args[0];
+ switch (subcommand) {
+ case "produce-consume":
+ produceConsume(requireOption(args, "--service-url"),
+ optionOrDefault(args, "--topic",
"native-image-smoke-test"));
+ break;
+ case "admin":
+ admin(requireOption(args, "--admin-url"));
+ break;
+ default:
+ throw new IllegalArgumentException("unknown subcommand: "
+ subcommand);
+ }
+ System.out.println(SUCCESS_MARKER);
+ System.exit(0);
+ } catch (Throwable t) {
+ System.out.println(FAILURE_MARKER + ": " + t.getClass().getName()
+ ": " + t.getMessage());
+ t.printStackTrace(System.err);
+ System.exit(1);
+ }
+ }
+
+ private static void produceConsume(String serviceUrl, String topic) throws
Exception {
+ final String payload = "Hello-from-native-image!";
+ try (PulsarClient client =
PulsarClient.builder().serviceUrl(serviceUrl).build();
+ Producer<String> producer = client.newProducer(Schema.STRING)
+ .topic(topic)
+ .enableBatching(false)
+ .create();
+ Consumer<String> consumer = client.newConsumer(Schema.STRING)
+ .topic(topic)
+ .subscriptionName("native-image-sub")
+ .subscriptionType(SubscriptionType.Exclusive)
+ .ackTimeout(10, TimeUnit.SECONDS)
+ .subscribe()) {
+
+ producer.send(payload);
+ Message<String> message = consumer.receive(30, TimeUnit.SECONDS);
+ if (message == null) {
+ throw new IllegalStateException("did not receive a message
within the timeout");
+ }
+ if (!payload.equals(message.getValue())) {
+ throw new IllegalStateException("unexpected message payload: "
+ message.getValue());
+ }
+ consumer.acknowledge(message);
+ System.out.println("produce-consume: received expected payload");
+ }
+ }
+
+ private static void admin(String adminUrl) throws Exception {
+ try (PulsarAdmin admin =
PulsarAdmin.builder().serviceHttpUrl(adminUrl).build()) {
+ // A read-only call that exercises the Jersey/HK2/Jackson REST
stack and
+ // deserializes a response, which is what stresses the admin
native-image config.
+ List<String> clusters = admin.clusters().getClusters();
+ List<String> tenants = admin.tenants().getTenants();
+ if (clusters == null || tenants == null) {
+ throw new IllegalStateException("admin returned a null
response");
+ }
+ System.out.println("admin: clusters=" + clusters + " tenants=" +
tenants);
+ }
+ }
+
+ /** Returns the value following {@code name}, or throws if the option is
absent. */
+ private static String requireOption(String[] args, String name) {
+ String value = optionOrDefault(args, name, null);
+ if (value == null) {
+ throw new IllegalArgumentException(name + " is required");
+ }
+ return value;
+ }
+
+ /** Returns the value following {@code name}, or {@code defaultValue} if
not present. */
+ private static String optionOrDefault(String[] args, String name, String
defaultValue) {
+ for (int i = 1; i < args.length - 1; i++) {
+ if (name.equals(args[i])) {
+ return args[i + 1];
+ }
+ }
+ return defaultValue;
+ }
+}
diff --git
a/tests/pulsar-client-native-image/src/main/java/org/apache/pulsar/tests/integration/nativeimage/package-info.java
b/tests/pulsar-client-native-image/src/main/java/org/apache/pulsar/tests/integration/nativeimage/package-info.java
new file mode 100644
index 00000000000..ad504e77b4f
--- /dev/null
+++
b/tests/pulsar-client-native-image/src/main/java/org/apache/pulsar/tests/integration/nativeimage/package-info.java
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ */
+/**
+ * A standalone Pulsar client application that is compiled to a GraalVM native
image to
+ * verify that the native-image metadata embedded in {@code
pulsar-client-original} and
+ * {@code pulsar-client-admin-original} is sufficient to build and run a
downstream
+ * application. Driven by {@code NativeImageSmokeTest} in this module's test
sources.
+ */
+package org.apache.pulsar.tests.integration.nativeimage;
diff --git
a/tests/pulsar-client-native-image/src/test/java/org/apache/pulsar/tests/integration/nativeimage/NativeImageSmokeTest.java
b/tests/pulsar-client-native-image/src/test/java/org/apache/pulsar/tests/integration/nativeimage/NativeImageSmokeTest.java
new file mode 100644
index 00000000000..37630b0dec2
--- /dev/null
+++
b/tests/pulsar-client-native-image/src/test/java/org/apache/pulsar/tests/integration/nativeimage/NativeImageSmokeTest.java
@@ -0,0 +1,197 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.pulsar.tests.integration.nativeimage;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.apache.pulsar.tests.TestRetrySupport;
+import org.testng.Assert;
+import org.testng.SkipException;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * Drives the natively-compiled {@link NativeImageTesterApp} binary against a
real Pulsar
+ * broker. The binary is built by the {@code native-maven-plugin} (see this
module's
+ * {@code nativeImageTests} profile) using only the GraalVM metadata embedded
in
+ * {@code pulsar-client-original} and {@code pulsar-client-admin-original}.
+ *
+ * <p>The fact that the binary builds at all proves the embedded
reflection/resource
+ * configuration is discovered from the classpath; these tests additionally
prove it works at
+ * runtime by performing produce/consume and an admin REST call.
+ */
+public class NativeImageSmokeTest extends TestRetrySupport {
+
+ private static final String SUCCESS_MARKER = "NATIVE_IMAGE_TEST_SUCCESS";
+ private static final int PROCESS_TIMEOUT_SECONDS = 120;
+
+ private PulsarContainer pulsarContainer;
+
+ @Override
+ @BeforeClass(alwaysRun = true)
+ public final void setup() {
+ incrementSetupNumber();
+ pulsarContainer = new PulsarContainer();
+ pulsarContainer.start();
+ }
+
+ @Test
+ public void nativeImageProduceConsume() throws Exception {
+ ProcessResult result = runNativeBinary(
+ "produce-consume",
+ "--service-url", pulsarContainer.getPlainTextPulsarBrokerUrl(),
+ "--topic", "native-image-smoke-test");
+ assertSuccess(result);
+ }
+
+ @Test
+ public void nativeImageAdmin() throws Exception {
+ ProcessResult result = runNativeBinary(
+ "admin",
+ "--admin-url", pulsarContainer.getPulsarAdminUrl());
+ assertSuccess(result);
+ }
+
+ private void assertSuccess(ProcessResult result) {
+ Assert.assertEquals(result.exitCode, 0,
+ "Native process exited non-zero.\n" + result);
+ Assert.assertTrue(result.stdout.contains(SUCCESS_MARKER),
+ "Expected success marker '" + SUCCESS_MARKER + "' not
found.\n" + result);
+ }
+
+ private ProcessResult runNativeBinary(String... arguments) throws
Exception {
+ File binary = locateNativeBinary();
+ if (!binary.exists()) {
+ throw new SkipException("Native binary not found at " +
binary.getAbsolutePath()
+ + " — build it with -PnativeImageTests using a GraalVM
native-image toolchain.");
+ }
+
+ List<String> command = new ArrayList<>();
+ command.add(binary.getAbsolutePath());
+ for (String argument : arguments) {
+ command.add(argument);
+ }
+
+ Process process = new
ProcessBuilder(command).redirectErrorStream(false).start();
+ // Drain stderr on a separate thread so a full stderr pipe buffer
cannot block the
+ // process while we read stdout (and vice versa).
+ StreamCollector stderrCollector = new
StreamCollector(process.getErrorStream());
+ Thread stderrThread = new Thread(stderrCollector,
"native-tester-stderr");
+ stderrThread.setDaemon(true);
+ stderrThread.start();
+
+ String stdout = new String(process.getInputStream().readAllBytes(),
StandardCharsets.UTF_8);
+
+ boolean finished = process.waitFor(PROCESS_TIMEOUT_SECONDS,
TimeUnit.SECONDS);
+ if (!finished) {
+ process.destroyForcibly();
+ }
+ stderrThread.join(TimeUnit.SECONDS.toMillis(10));
+ String stderr = stderrCollector.toString();
+
+ if (!finished) {
+ Assert.fail("Native process timed out after " +
PROCESS_TIMEOUT_SECONDS + "s.\n"
+ + new ProcessResult(-1, stdout, stderr));
+ }
+ return new ProcessResult(process.exitValue(), stdout, stderr);
+ }
+
+ private File locateNativeBinary() {
+ // The Gradle build passes the absolute path of the GraalVM
nativeCompile output.
+ String binaryPath = System.getProperty("native.image.binary");
+ if (binaryPath != null && !binaryPath.isEmpty()) {
+ Path explicit = Path.of(binaryPath);
+ if (Files.exists(explicit)) {
+ return explicit.toFile();
+ }
+ Path explicitWindows = Path.of(binaryPath + ".exe");
+ if (Files.exists(explicitWindows)) {
+ return explicitWindows.toFile();
+ }
+ return explicit.toFile();
+ }
+ String buildDir = System.getProperty("native.image.directory",
"build/native/nativeCompile");
+ String imageName = System.getProperty("native.image.name",
"pulsar-client-native-tester");
+ Path path = Path.of(buildDir, imageName);
+ // GraalVM appends .exe on Windows.
+ if (!Files.exists(path)) {
+ Path windowsPath = Path.of(buildDir, imageName + ".exe");
+ if (Files.exists(windowsPath)) {
+ return windowsPath.toFile();
+ }
+ }
+ return path.toFile();
+ }
+
+ @Override
+ @AfterClass(alwaysRun = true)
+ public final void cleanup() {
+ markCurrentSetupNumberCleaned();
+ if (pulsarContainer != null) {
+ pulsarContainer.stop();
+ }
+ }
+
+ /** Collects an input stream's full content as UTF-8 text on a background
thread. */
+ private static final class StreamCollector implements Runnable {
+ private final java.io.InputStream stream;
+ private volatile String content = "";
+
+ StreamCollector(java.io.InputStream stream) {
+ this.stream = stream;
+ }
+
+ @Override
+ public void run() {
+ try {
+ content = new String(stream.readAllBytes(),
StandardCharsets.UTF_8);
+ } catch (Exception e) {
+ content = "<failed to read stderr: " + e + ">";
+ }
+ }
+
+ @Override
+ public String toString() {
+ return content;
+ }
+ }
+
+ private static final class ProcessResult {
+ final int exitCode;
+ final String stdout;
+ final String stderr;
+
+ ProcessResult(int exitCode, String stdout, String stderr) {
+ this.exitCode = exitCode;
+ this.stdout = stdout;
+ this.stderr = stderr;
+ }
+
+ @Override
+ public String toString() {
+ return "exitCode=" + exitCode + "\n--- stdout ---\n" + stdout +
"\n--- stderr ---\n" + stderr;
+ }
+ }
+}
diff --git
a/tests/pulsar-client-native-image/src/test/java/org/apache/pulsar/tests/integration/nativeimage/PulsarContainer.java
b/tests/pulsar-client-native-image/src/test/java/org/apache/pulsar/tests/integration/nativeimage/PulsarContainer.java
new file mode 100644
index 00000000000..c3a88ae95d0
--- /dev/null
+++
b/tests/pulsar-client-native-image/src/test/java/org/apache/pulsar/tests/integration/nativeimage/PulsarContainer.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.pulsar.tests.integration.nativeimage;
+
+import static java.time.temporal.ChronoUnit.SECONDS;
+import java.time.Duration;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.HttpWaitStrategy;
+
+/**
+ * A Pulsar standalone broker started in a container for the native-image
smoke tests.
+ * Mirrors the helper used by {@code pulsar-client-shade-test}.
+ */
+public class PulsarContainer extends GenericContainer<PulsarContainer> {
+
+ public static final int PULSAR_PORT = 6650;
+ public static final int BROKER_HTTP_PORT = 8080;
+ public static final String DEFAULT_IMAGE_NAME =
System.getenv().getOrDefault("PULSAR_TEST_IMAGE_NAME",
+ "apachepulsar/pulsar-test-latest-version:latest");
+
+ public PulsarContainer() {
+ this(DEFAULT_IMAGE_NAME);
+ }
+
+ public PulsarContainer(final String pulsarVersion) {
+ super(pulsarVersion);
+ withExposedPorts(BROKER_HTTP_PORT, PULSAR_PORT);
+ withCommand("/pulsar/bin/pulsar standalone");
+ waitingFor(new HttpWaitStrategy()
+ .forPort(BROKER_HTTP_PORT)
+ .forStatusCode(200)
+ .forPath("/admin/v2/namespaces/public/default")
+ .withStartupTimeout(Duration.of(300, SECONDS)));
+ }
+
+ public String getPlainTextPulsarBrokerUrl() {
+ return String.format("pulsar://%s:%s", this.getHost(),
this.getMappedPort(PULSAR_PORT));
+ }
+
+ public String getPulsarAdminUrl() {
+ return String.format("http://%s:%s", this.getHost(),
this.getMappedPort(BROKER_HTTP_PORT));
+ }
+
+}
diff --git
a/tests/pulsar-client-native-image/src/test/resources/native-image-tests.xml
b/tests/pulsar-client-native-image/src/test/resources/native-image-tests.xml
new file mode 100644
index 00000000000..9f4553c9919
--- /dev/null
+++ b/tests/pulsar-client-native-image/src/test/resources/native-image-tests.xml
@@ -0,0 +1,28 @@
+<!--
+
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you 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.
+
+-->
+<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd" >
+<suite name="Pulsar Native Image Tests" verbose="2" annotations="JDK">
+ <test name="pulsar-client-native-image-suite" preserve-order="true" >
+ <classes>
+ <class
name="org.apache.pulsar.tests.integration.nativeimage.NativeImageSmokeTest" />
+ </classes>
+ </test>
+</suite>