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>

Reply via email to