diff --git a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java index 1a6726b9c01b..4f49c2a689f5 100644 --- a/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java +++ b/java-storage/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageOptions.java @@ -134,6 +134,8 @@ public final class GrpcStorageOptions extends StorageOptions private static final String GCS_SCOPE = "https://www.googleapis.com/auth/devstorage.full_control"; private static final Set SCOPES = ImmutableSet.of(GCS_SCOPE); private static final String DEFAULT_HOST = "https://storage.googleapis.com"; + private static final String DEFAULT_HOST_NO_SCHEME = "storage.googleapis.com"; + private static final String DEFAULT_HOST_DIRECT_PATH_NO_SCHEME = "storage-direct.googleapis.com"; // If true, disable the bound-token-by-default feature for DirectPath. private static final boolean DIRECT_PATH_BOUND_TOKEN_DISABLED = Boolean.parseBoolean( @@ -142,6 +144,7 @@ public final class GrpcStorageOptions extends StorageOptions private final GrpcRetryAlgorithmManager retryAlgorithmManager; private final java.time.Duration terminationAwaitDuration; private final boolean attemptDirectPath; + private final boolean attemptDirectPathXdsOverInterconnect; private final boolean enableGrpcClientMetrics; private final boolean grpcClientMetricsManuallyEnabled; @@ -160,6 +163,7 @@ private GrpcStorageOptions(Builder builder, GrpcStorageDefaults serviceDefaults) builder.terminationAwaitDuration, serviceDefaults.getTerminationAwaitDurationJavaTime()); this.attemptDirectPath = builder.attemptDirectPath; + this.attemptDirectPathXdsOverInterconnect = builder.attemptDirectPathXdsOverInterconnect; this.enableGrpcClientMetrics = builder.enableGrpcClientMetrics; this.grpcClientMetricsManuallyEnabled = builder.grpcMetricsManuallyEnabled; this.grpcInterceptorProvider = builder.grpcInterceptorProvider; @@ -197,6 +201,27 @@ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundE this.openTelemetry = HttpStorageOptions.getDefaultInstance().getOpenTelemetry(); } + static String rewriteHost(String endpoint, String oldHost, String newHost) { + String prefix = ""; + String rest = endpoint; + int schemeIndex = endpoint.indexOf("://"); + if (schemeIndex >= 0) { + prefix = endpoint.substring(0, schemeIndex + 3); + rest = endpoint.substring(schemeIndex + 3); + } + if (rest.startsWith(oldHost)) { + int len = oldHost.length(); + if (rest.length() == len + || rest.charAt(len) == ':' + || rest.charAt(len) == '/' + || rest.charAt(len) == '?' + || rest.charAt(len) == '#') { + return prefix + newHost + rest.substring(len); + } + } + return endpoint; + } + /** * We have to perform several introspections and detections to cross-wire/support several features * that are either gapic primitives, ServiceOption primitives or GCS semantic requirements. @@ -230,6 +255,9 @@ private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundE */ private Tuple> resolveSettingsAndOpts() throws IOException { String endpoint = getHost(); + if (attemptDirectPathXdsOverInterconnect) { + endpoint = rewriteHost(endpoint, DEFAULT_HOST_NO_SCHEME, DEFAULT_HOST_DIRECT_PATH_NO_SCHEME); + } URI uri = URI.create(endpoint); String scheme = uri.getScheme(); int port = uri.getPort(); @@ -322,7 +350,8 @@ private Tuple> resolveSettingsAndOpts() throw InstantiatingGrpcChannelProvider.newBuilder() .setEndpoint(endpoint) .setAllowNonDefaultServiceAccount(true) - .setAttemptDirectPath(attemptDirectPath); + .setAttemptDirectPath(attemptDirectPath || attemptDirectPathXdsOverInterconnect) + .setAttemptDirectPathXdsOverInterconnect(attemptDirectPathXdsOverInterconnect); if (!DIRECT_PATH_BOUND_TOKEN_DISABLED) { channelProviderBuilder.setAllowHardBoundTokenTypes( @@ -428,6 +457,7 @@ public int hashCode() { retryAlgorithmManager, terminationAwaitDuration, attemptDirectPath, + attemptDirectPathXdsOverInterconnect, enableGrpcClientMetrics, grpcInterceptorProvider, blobWriteSessionConfig, @@ -445,6 +475,7 @@ public boolean equals(Object o) { } GrpcStorageOptions that = (GrpcStorageOptions) o; return attemptDirectPath == that.attemptDirectPath + && attemptDirectPathXdsOverInterconnect == that.attemptDirectPathXdsOverInterconnect && enableGrpcClientMetrics == that.enableGrpcClientMetrics && Objects.equals(retryAlgorithmManager, that.retryAlgorithmManager) && Objects.equals(terminationAwaitDuration, that.terminationAwaitDuration) @@ -494,6 +525,7 @@ public static final class Builder extends StorageOptions.Builder { private StorageRetryStrategy storageRetryStrategy; private java.time.Duration terminationAwaitDuration; private boolean attemptDirectPath = GrpcStorageDefaults.INSTANCE.isAttemptDirectPath(); + private boolean attemptDirectPathXdsOverInterconnect = false; private boolean enableGrpcClientMetrics = GrpcStorageDefaults.INSTANCE.isEnableGrpcClientMetrics(); private GrpcInterceptorProvider grpcInterceptorProvider = @@ -512,6 +544,7 @@ public static final class Builder extends StorageOptions.Builder { this.storageRetryStrategy = gso.getRetryAlgorithmManager().retryStrategy; this.terminationAwaitDuration = gso.getTerminationAwaitDuration(); this.attemptDirectPath = gso.attemptDirectPath; + this.attemptDirectPathXdsOverInterconnect = gso.attemptDirectPathXdsOverInterconnect; this.enableGrpcClientMetrics = gso.enableGrpcClientMetrics; this.grpcInterceptorProvider = gso.grpcInterceptorProvider; this.blobWriteSessionConfig = gso.blobWriteSessionConfig; @@ -556,6 +589,19 @@ public GrpcStorageOptions.Builder setAttemptDirectPath(boolean attemptDirectPath return this; } + /** + * Option for whether this client should attempt to use DirectPath over Interconnect (on-premise + * xDS name resolution). + * + * @since 2.72.0 + */ + @BetaApi + public GrpcStorageOptions.Builder setAttemptDirectPathXdsOverInterconnect( + boolean attemptDirectPathXdsOverInterconnect) { + this.attemptDirectPathXdsOverInterconnect = attemptDirectPathXdsOverInterconnect; + return this; + } + /** * Option for whether this client should emit internal gRPC client internal metrics to Cloud * Monitoring. To disable metric reporting, set this to false. True by default. Emitting metrics diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOptionsBuilderTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOptionsBuilderTest.java index 240040519635..17fca16d5d3c 100644 --- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOptionsBuilderTest.java +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOptionsBuilderTest.java @@ -69,6 +69,132 @@ public void grpc() throws Exception { () -> assertThat(rebuilt.hashCode()).isEqualTo(base.hashCode())); } + @Test + public void grpc_attemptDirectPathXdsOverInterconnect() throws Exception { + com.google.auth.Credentials mockCreds = com.google.cloud.NoCredentials.getInstance(); + GrpcStorageOptions options = + GrpcStorageOptions.grpc() + .setCredentials(mockCreds) + .setAttemptDirectPathXdsOverInterconnect(true) + .build(); + + GrpcStorageOptions rebuilt = options.toBuilder().build(); + assertAll( + () -> assertThat(rebuilt).isEqualTo(options), + () -> assertThat(rebuilt.hashCode()).isEqualTo(options.hashCode())); + + com.google.storage.v2.StorageSettings settings = options.getStorageSettings(); + assertThat(settings.getEndpoint()).isEqualTo("storage-direct.googleapis.com:443"); + + com.google.api.gax.rpc.TransportChannelProvider tcp = settings.getTransportChannelProvider(); + assertThat(tcp).isInstanceOf(com.google.api.gax.grpc.InstantiatingGrpcChannelProvider.class); + com.google.api.gax.grpc.InstantiatingGrpcChannelProvider provider = + (com.google.api.gax.grpc.InstantiatingGrpcChannelProvider) tcp; + + assertThat(provider.isAttemptDirectPathXdsOverInterconnect()).isTrue(); + com.google.api.core.ApiFunction + configurator = provider.toBuilder().getChannelConfigurator(); + if (configurator != null) { + io.grpc.ManagedChannelBuilder mockBuilder = + org.mockito.Mockito.mock( + io.grpc.ManagedChannelBuilder.class, org.mockito.Mockito.RETURNS_SELF); + configurator.apply(mockBuilder); + org.mockito.Mockito.verify(mockBuilder, org.mockito.Mockito.never()) + .overrideAuthority(org.mockito.Mockito.anyString()); + } + } + + @Test + public void grpc_fallbackFromDirectPathXdsOverInterconnect_doesNotOverrideAuthority() + throws Exception { + GrpcStorageOptions options = + GrpcStorageOptions.grpc() + .setHost("https://storage.my-universe.com") + .setAttemptDirectPathXdsOverInterconnect(true) + .setCredentials(com.google.cloud.NoCredentials.getInstance()) + .build(); + com.google.api.gax.grpc.InstantiatingGrpcChannelProvider provider = + (com.google.api.gax.grpc.InstantiatingGrpcChannelProvider) + options.getStorageSettings().getTransportChannelProvider(); + com.google.api.core.ApiFunction + configurator = provider.toBuilder().getChannelConfigurator(); + if (configurator != null) { + io.grpc.ManagedChannelBuilder mockBuilder = + org.mockito.Mockito.mock( + io.grpc.ManagedChannelBuilder.class, org.mockito.Mockito.RETURNS_SELF); + configurator.apply(mockBuilder); + org.mockito.Mockito.verify(mockBuilder, org.mockito.Mockito.never()) + .overrideAuthority(org.mockito.Mockito.anyString()); + } + } + + @Test + public void grpc_attemptDirectPathXdsOverInterconnect_hostWithQueryOrFragment() throws Exception { + GrpcStorageOptions optionsWithQuery = + GrpcStorageOptions.grpc() + .setHost("https://storage.googleapis.com?query=val") + .setCredentials(com.google.cloud.NoCredentials.getInstance()) + .setAttemptDirectPathXdsOverInterconnect(true) + .build(); + assertThat(optionsWithQuery.getStorageSettings().getEndpoint()) + .isEqualTo("storage-direct.googleapis.com:443"); + + GrpcStorageOptions optionsWithFragment = + GrpcStorageOptions.grpc() + .setHost("https://storage.googleapis.com#section") + .setCredentials(com.google.cloud.NoCredentials.getInstance()) + .setAttemptDirectPathXdsOverInterconnect(true) + .build(); + assertThat(optionsWithFragment.getStorageSettings().getEndpoint()) + .isEqualTo("storage-direct.googleapis.com:443"); + } + + @Test + public void grpc_rewriteHost_delimiters() { + assertThat( + GrpcStorageOptions.rewriteHost( + "https://storage.googleapis.com?query=val", + "storage.googleapis.com", + "storage-direct.googleapis.com")) + .isEqualTo("https://storage-direct.googleapis.com?query=val"); + assertThat( + GrpcStorageOptions.rewriteHost( + "https://storage.googleapis.com#section", + "storage.googleapis.com", + "storage-direct.googleapis.com")) + .isEqualTo("https://storage-direct.googleapis.com#section"); + assertThat( + GrpcStorageOptions.rewriteHost( + "storage.googleapis.com?query=val", + "storage.googleapis.com", + "storage-direct.googleapis.com")) + .isEqualTo("storage-direct.googleapis.com?query=val"); + assertThat( + GrpcStorageOptions.rewriteHost( + "storage.googleapis.com#section", + "storage.googleapis.com", + "storage-direct.googleapis.com")) + .isEqualTo("storage-direct.googleapis.com#section"); + assertThat( + GrpcStorageOptions.rewriteHost( + "storage.googleapis.com:443", + "storage.googleapis.com", + "storage-direct.googleapis.com")) + .isEqualTo("storage-direct.googleapis.com:443"); + assertThat( + GrpcStorageOptions.rewriteHost( + "storage.googleapis.com/path", + "storage.googleapis.com", + "storage-direct.googleapis.com")) + .isEqualTo("storage-direct.googleapis.com/path"); + assertThat( + GrpcStorageOptions.rewriteHost( + "storage.googleapis.com.evil.com", + "storage.googleapis.com", + "storage-direct.googleapis.com")) + .isEqualTo("storage.googleapis.com.evil.com"); + } + @Test public void useJwtAccessWithScope_defaultsToFalse() { HttpStorageOptions httpOptions = HttpStorageOptions.http().build(); diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITGrpcDirectPathTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITGrpcDirectPathTest.java new file mode 100644 index 000000000000..f994d17e60be --- /dev/null +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITGrpcDirectPathTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.storage.it; + +import static org.junit.Assume.assumeTrue; + +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageOptions; +import com.google.cloud.storage.TransportCompatibility.Transport; +import com.google.cloud.storage.it.runner.StorageITRunner; +import com.google.cloud.storage.it.runner.annotations.Backend; +import com.google.cloud.storage.it.runner.annotations.Inject; +import com.google.cloud.storage.it.runner.annotations.SingleBackend; +import com.google.cloud.storage.it.runner.annotations.StorageFixture; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(StorageITRunner.class) +@SingleBackend(Backend.PROD) +public final class ITGrpcDirectPathTest { + + @Inject + @StorageFixture(Transport.GRPC) + public Storage storage; + + @Ignore( + "Bypassed because DirectPath over Interconnect (GCI) requires a specialized hybrid network environment (Interconnect and Traffic Director configured for storage-direct) and cannot be validated in standard CI or local workstations.") + @Test + public void clientShouldWork_directPathXdsOverInterconnect() throws Exception { + // Added assumeTrue to ensure the test skips gracefully if storage-direct.googleapis.com + // cannot be resolved rather than failing CI. + assumeTrue( + "Environment cannot resolve storage-direct.googleapis.com", canResolveDirectPathAddress()); + StorageOptions options = + StorageOptions.grpc() + .setCredentials(storage.getOptions().getCredentials()) + .setAttemptDirectPathXdsOverInterconnect(true) + .setEnableGrpcClientMetrics(false) + .build(); + try (Storage client = options.getService()) { + client.list(Storage.BucketListOption.pageSize(1)); + } + } + + private static boolean canResolveDirectPathAddress() { + try { + java.net.InetAddress.getAllByName("storage-direct.googleapis.com"); + return true; + } catch (java.net.UnknownHostException e) { + return false; + } + } +} diff --git a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageOptionsTest.java b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageOptionsTest.java index 0d8114a7fb2d..898725de2633 100644 --- a/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageOptionsTest.java +++ b/java-storage/google-cloud-storage/src/test/java/com/google/cloud/storage/it/ITStorageOptionsTest.java @@ -84,6 +84,17 @@ public void clientShouldConstructCleanly_directPath() throws Exception { doTest(options); } + @Test + public void clientShouldConstructCleanly_directPathXdsOverInterconnect() throws Exception { + StorageOptions options = + StorageOptions.grpc() + .setCredentials(credentials) + .setAttemptDirectPathXdsOverInterconnect(true) + .setEnableGrpcClientMetrics(false) + .build(); + doTest(options); + } + @Test public void lackOfProjectIdDoesNotPreventConstruction_http() throws Exception { StorageOptions options = StorageOptions.http().setCredentials(credentials).build(); diff --git a/sdk-platform-java/gax-java/gax-grpc/BUILD.bazel b/sdk-platform-java/gax-java/gax-grpc/BUILD.bazel index 52bec965145b..f615eb9e3294 100644 --- a/sdk-platform-java/gax-java/gax-grpc/BUILD.bazel +++ b/sdk-platform-java/gax-java/gax-grpc/BUILD.bazel @@ -37,6 +37,7 @@ _COMPILE_DEPS = [ _TEST_COMPILE_DEPS = [ "@org_junit_jupiter_junit_jupiter_api//jar", + "@org_junit_jupiter_junit_jupiter_params//jar", "@org_mockito_mockito_core//jar", "@org_mockito_mockito_junit_jupiter//jar", "@com_google_truth_truth//jar", diff --git a/sdk-platform-java/gax-java/gax-grpc/pom.xml b/sdk-platform-java/gax-java/gax-grpc/pom.xml index bae5e8f20150..c965e6488403 100644 --- a/sdk-platform-java/gax-java/gax-grpc/pom.xml +++ b/sdk-platform-java/gax-java/gax-grpc/pom.xml @@ -123,6 +123,12 @@ ${junit.version} test + + org.junit.jupiter + junit-jupiter-params + ${junit.version} + test + com.google.http-client google-http-client @@ -158,7 +164,7 @@ maven-surefire-plugin - !InstantiatingGrpcChannelProviderTest#testLogDirectPathMisconfig_AttemptDirectPathNotSetAndAttemptDirectPathXdsSetViaEnv_warns,!InstantiatingGrpcChannelProviderTest#canUseDirectPath_directPathEnvVarNotSet_attemptDirectPathIsTrue,InstantiatingGrpcChannelProviderTest#testLogDirectPathMisconfigWrongCredential + !InstantiatingGrpcChannelProviderTest#testLogDirectPathMisconfig_AttemptDirectPathNotSetAndAttemptDirectPathXdsSetViaEnv_warns,!InstantiatingGrpcChannelProviderTest#canUseDirectPath_directPathEnvVarNotSet_attemptDirectPathIsTrue diff --git a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java index f300ac611da8..61cfae725665 100644 --- a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java +++ b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java @@ -111,6 +111,8 @@ public final class InstantiatingGrpcChannelProvider implements TransportChannelP @VisibleForTesting static final String DIRECT_PATH_ENV_ENABLE_XDS = "GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS"; + private static final String DIRECT_PATH_INTERCONNECT_INFIX = "-direct."; + // The public portion of the mTLS MDS root certificate is stored for performing // cert verification when establishing an mTLS connection with the MDS. See // {@link directPathServiceConfig; private final @Nullable MtlsProvider mtlsProvider; private final CertificateBasedAccess certificateBasedAccess; @@ -236,6 +239,7 @@ private InstantiatingGrpcChannelProvider(Builder builder) { this.channelPrimer = builder.channelPrimer; this.attemptDirectPath = builder.attemptDirectPath; this.attemptDirectPathXds = builder.attemptDirectPathXds; + this.attemptDirectPathXdsOverInterconnect = builder.attemptDirectPathXdsOverInterconnect; this.allowNonDefaultServiceAccount = builder.allowNonDefaultServiceAccount; this.directPathServiceConfig = builder.directPathServiceConfig == null @@ -394,7 +398,7 @@ public TransportChannel getTransportChannel() throws IOException { } else if (needsEndpoint()) { throw new IllegalStateException("getTransportChannel() called when needsEndpoint() is true"); } else { - logDirectPathMisconfig(); + validateDirectPathState(); return createChannel(); } } @@ -432,6 +436,11 @@ private boolean isDirectPathXdsEnabledViaEnv() { return Boolean.parseBoolean(directPathXdsEnv); } + @InternalApi + public boolean isAttemptDirectPathXdsOverInterconnect() { + return Boolean.TRUE.equals(attemptDirectPathXdsOverInterconnect); + } + /** * This method tells if Direct Path xDS was enabled. There are two ways of enabling it: via * environment variable (by setting GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS=true) or when building @@ -441,21 +450,19 @@ private boolean isDirectPathXdsEnabledViaEnv() { */ @InternalApi public boolean isDirectPathXdsEnabled() { - return isDirectPathXdsEnabledViaEnv() || isDirectPathXdsEnabledViaBuilderOption(); + return isDirectPathXdsEnabledViaEnv() + || isDirectPathXdsEnabledViaBuilderOption() + || isAttemptDirectPathXdsOverInterconnect(); } // This method should be called once per client initialization, hence can not be called in the // builder or createSingleChannel, only in getTransportChannel which creates the first channel // for a client. - private void logDirectPathMisconfig() { - if (!isDirectPathXdsEnabled()) { - return; - } - + @InternalApi + public void validateDirectPathState() { Level level = isOnComputeEngine() ? Level.WARNING : Level.FINE; if (!isDirectPathEnabled()) { - // This misconfiguration occurs when Direct Path xDS is enabled, but Direct Path is not // Direct Path xDS can be enabled two ways: via environment variable or via builder. // Case 1: Direct Path is only enabled via xDS env var. We will _warn_ the user that this is // a misconfiguration if they intended to set the env var. @@ -464,8 +471,9 @@ private void logDirectPathMisconfig() { level, "Env var " + DIRECT_PATH_ENV_ENABLE_XDS - + " was found and set to TRUE, but DirectPath was not enabled for this client. If this is intended for " - + "this client, please note that this is a misconfiguration and set the attemptDirectPath option as well."); + + " was found and set to TRUE, but DirectPath was not enabled for this client. If" + + " this is intended for this client, please note that this is a misconfiguration" + + " and set the attemptDirectPath option as well."); } // Case 2: Direct Path xDS was enabled via Builder. Direct Path Traffic Director must be set // (enabled with `setAttemptDirectPath(true)`) along with xDS. @@ -473,10 +481,19 @@ private void logDirectPathMisconfig() { else if (isDirectPathXdsEnabledViaBuilderOption()) { LOG.log( level, - "DirectPath is misconfigured. The DirectPath XDS option was set, but the attemptDirectPath option was not. Please set both the attemptDirectPath and attemptDirectPathXds options."); + "DirectPath is misconfigured. The DirectPath XDS option was set, but the" + + " attemptDirectPath option was not. Please set both the attemptDirectPath and" + + " attemptDirectPathXds options."); } } else { - // Case 3: credential is not correctly set + // Case 3: DirectPath is enabled, but xDS is not. + if (!isDirectPathXdsEnabled()) { + LOG.log( + level, + "DirectPath is enabled, but DirectPath xDS is not. Please note that DirectPath will" + + " soon require xDS to be enabled. Please set the attemptDirectPathXds option."); + } + // Case 4: credential is not correctly set if (!isCredentialDirectPathCompatible()) { LOG.log( level, @@ -484,8 +501,8 @@ else if (isDirectPathXdsEnabledViaBuilderOption()) { + ComputeEngineCredentials.class.getName() + " ."); } - // Case 4: not running on GCE - if (!isOnComputeEngine()) { + // Case 5: not running on GCE + if (!isOnComputeEngine() && !isAttemptDirectPathXdsOverInterconnect()) { LOG.log( level, "DirectPath is misconfigured. DirectPath is only available in a GCE environment."); @@ -495,6 +512,10 @@ else if (isDirectPathXdsEnabledViaBuilderOption()) { @VisibleForTesting boolean isCredentialDirectPathCompatible() { + // xDS over Interconnect is designed to work on-premise using arbitrary service credentials. + if (isAttemptDirectPathXdsOverInterconnect()) { + return true; + } // DirectPath requires a call credential during gRPC channel construction. if (needsCredentials()) { return false; @@ -664,7 +685,8 @@ ChannelCredentials createS2ASecuredChannelCredentials() { // Fallback to plaintext connection to S2A. LOG.log( Level.INFO, - "Cannot establish an mTLS connection to S2A because autoconfig endpoint did not return a mtls address to reach S2A."); + "Cannot establish an mTLS connection to S2A because autoconfig endpoint did not" + + " return a mtls address to reach S2A."); s2aChannelCredentials = createPlaintextToS2AChannelCredentials(plaintextAddress); return s2aChannelCredentials; } @@ -683,7 +705,9 @@ ChannelCredentials createS2ASecuredChannelCredentials() { // Fallback to plaintext-to-S2A connection on error. LOG.log( Level.WARNING, - "Cannot establish an mTLS connection to S2A due to error creating MTLS to MDS TlsChannelCredentials credentials, falling back to plaintext connection to S2A: " + "Cannot establish an mTLS connection to S2A due to error creating MTLS to MDS" + + " TlsChannelCredentials credentials, falling back to plaintext connection to" + + " S2A: " + ignore.getMessage()); s2aChannelCredentials = createPlaintextToS2AChannelCredentials(plaintextAddress); return s2aChannelCredentials; @@ -705,75 +729,123 @@ ChannelCredentials createS2ASecuredChannelCredentials() { return s2aChannelCredentials; } - @InternalApi("For internal use by google-cloud-java clients only") - public ManagedChannelBuilder createChannelBuilder() throws IOException { - int colon = endpoint.lastIndexOf(':'); - if (colon < 0) { - throw new IllegalStateException("invalid endpoint - should have been validated: " + endpoint); + private ChannelCredentials getGoogleDefaultChannelCredentials() { + GoogleDefaultChannelCredentials.Builder builder = GoogleDefaultChannelCredentials.newBuilder(); + if (credentials != null) { + builder.callCredentials(MoreCallCredentials.from(credentials)); + } + if (altsCallCredentials != null) { + builder.altsCallCredentials(altsCallCredentials); } - int port = Integer.parseInt(endpoint.substring(colon + 1)); - String serviceAddress = endpoint.substring(0, colon); + return builder.build(); + } + @InternalApi("For internal use by google-cloud-java clients only") + public ManagedChannelBuilder createChannelBuilder() throws IOException { ManagedChannelBuilder builder; - - // Check DirectPath traffic. boolean useDirectPathXds = false; - if (canUseDirectPath()) { - CallCredentials callCreds = MoreCallCredentials.from(credentials); - // altsCallCredentials may be null and GoogleDefaultChannelCredentials - // will solely use callCreds. Otherwise it uses altsCallCredentials - // for DirectPath connections and callCreds for CloudPath fallbacks. - ChannelCredentials channelCreds = - GoogleDefaultChannelCredentials.newBuilder() - .callCredentials(callCreds) - .altsCallCredentials(altsCallCredentials) - .build(); - useDirectPathXds = isDirectPathXdsEnabled(); - if (useDirectPathXds) { - // google-c2p: CloudToProd(C2P) Directpath. This scheme is defined in - // io.grpc.googleapis.GoogleCloudToProdNameResolverProvider. - // This resolver target must not have a port number. - builder = Grpc.newChannelBuilder("google-c2p:///" + serviceAddress, channelCreds); - } else { - builder = Grpc.newChannelBuilderForAddress(serviceAddress, port, channelCreds); - builder.defaultServiceConfig(directPathServiceConfig); + String resolvedTarget; + + // If the endpoint is already a custom URI scheme target (e.g. google-c2p:///), use it directly. + if (endpoint.contains(":///")) { + ChannelCredentials channelCreds = getGoogleDefaultChannelCredentials(); + builder = Grpc.newChannelBuilder(endpoint, channelCreds); + resolvedTarget = endpoint; + if (endpoint.startsWith("google-c2p:///")) { + useDirectPathXds = true; + // Set default keepAliveTime and keepAliveTimeout when directpath environment is enabled. + // Will be overridden by user defined values if any. + builder.keepAliveTime(DIRECT_PATH_KEEP_ALIVE_TIME_SECONDS, TimeUnit.SECONDS); + builder.keepAliveTimeout(DIRECT_PATH_KEEP_ALIVE_TIMEOUT_SECONDS, TimeUnit.SECONDS); } - // Set default keepAliveTime and keepAliveTimeout when directpath environment is enabled. - // Will be overridden by user defined values if any. - builder.keepAliveTime(DIRECT_PATH_KEEP_ALIVE_TIME_SECONDS, TimeUnit.SECONDS); - builder.keepAliveTimeout(DIRECT_PATH_KEEP_ALIVE_TIMEOUT_SECONDS, TimeUnit.SECONDS); } else { - ChannelCredentials channelCredentials; - try { - // Try and create credentials via DCA. See https://google.aip.dev/auth/4114. - channelCredentials = createMtlsChannelCredentials(); - } catch (GeneralSecurityException e) { - throw new IOException(e); + int colon = endpoint.lastIndexOf(':'); + if (colon < 0) { + throw new IllegalStateException( + "invalid endpoint - should have been validated: " + endpoint); } - if (channelCredentials != null) { - // Create the channel using channel credentials created via DCA. - builder = Grpc.newChannelBuilder(endpoint, channelCredentials); + int port = Integer.parseInt(endpoint.substring(colon + 1)); + String serviceAddress = endpoint.substring(0, colon); + + // Check DirectPath traffic. + if (canUseDirectPath()) { + ChannelCredentials channelCreds = getGoogleDefaultChannelCredentials(); + useDirectPathXds = isDirectPathXdsEnabled() || isAttemptDirectPathXdsOverInterconnect(); + if (useDirectPathXds) { + // google-c2p: CloudToProd(C2P) Directpath. This scheme is defined in + // io.grpc.googleapis.GoogleCloudToProdNameResolverProvider. + // This resolver target must not have a port number. + String target = "google-c2p:///" + serviceAddress; + if (isAttemptDirectPathXdsOverInterconnect()) { + target += "?force-xds"; + } + builder = Grpc.newChannelBuilder(target, channelCreds); + if (isAttemptDirectPathXdsOverInterconnect() + && serviceAddress.contains(DIRECT_PATH_INTERCONNECT_INFIX)) { + builder.overrideAuthority(serviceAddress.replace(DIRECT_PATH_INTERCONNECT_INFIX, ".")); + } + resolvedTarget = target; + } else { + builder = Grpc.newChannelBuilderForAddress(serviceAddress, port, channelCreds); + builder.defaultServiceConfig(directPathServiceConfig); + resolvedTarget = serviceAddress + ":" + port; + } + // Set default keepAliveTime and keepAliveTimeout when directpath environment is enabled. + // Will be overridden by user defined values if any. + builder.keepAliveTime(DIRECT_PATH_KEEP_ALIVE_TIME_SECONDS, TimeUnit.SECONDS); + builder.keepAliveTimeout(DIRECT_PATH_KEEP_ALIVE_TIMEOUT_SECONDS, TimeUnit.SECONDS); } else { - // Could not create channel credentials via DCA. In accordance with - // https://google.aip.dev/auth/4115, if credentials not available through - // DCA, try mTLS with credentials held by the S2A (Secure Session Agent). - if (useS2A) { - channelCredentials = createS2ASecuredChannelCredentials(); + if (isDirectPathEnabled() || isAttemptDirectPathXdsOverInterconnect()) { + LOG.log( + Level.WARNING, + "DirectPath was requested but is not available. Falling back to CloudPath."); } - if (channelCredentials != null) { - // Create the channel using S2A-secured channel credentials. - if (mtlsS2ACallCredentials != null) { - // Set {@code mtlsS2ACallCredentials} to be per-RPC call credentials, - // which will be used to fetch MTLS_S2A hard bound tokens from the metdata server. - channelCredentials = - CompositeChannelCredentials.create(channelCredentials, mtlsS2ACallCredentials); + String fallbackEndpoint = endpoint; + String fallbackMtlsEndpoint = mtlsEndpoint; + if (serviceAddress.contains(DIRECT_PATH_INTERCONNECT_INFIX)) { + serviceAddress = serviceAddress.replace(DIRECT_PATH_INTERCONNECT_INFIX, "."); + fallbackEndpoint = serviceAddress + ":" + port; + if (fallbackMtlsEndpoint != null + && fallbackMtlsEndpoint.contains(DIRECT_PATH_INTERCONNECT_INFIX)) { + fallbackMtlsEndpoint = + fallbackMtlsEndpoint.replace(DIRECT_PATH_INTERCONNECT_INFIX, "."); } - // Connect to the MTLS endpoint when using S2A because S2A is used to perform an MTLS - // handshake. - builder = Grpc.newChannelBuilder(mtlsEndpoint, channelCredentials); + } + ChannelCredentials channelCredentials; + try { + // Try and create credentials via DCA. See https://google.aip.dev/auth/4114. + channelCredentials = createMtlsChannelCredentials(); + } catch (GeneralSecurityException e) { + throw new IOException(e); + } + if (channelCredentials != null) { + // Create the channel using channel credentials created via DCA. + builder = Grpc.newChannelBuilder(fallbackEndpoint, channelCredentials); + resolvedTarget = fallbackEndpoint; } else { - // Use default if we cannot initialize channel credentials via DCA or S2A. - builder = ManagedChannelBuilder.forAddress(serviceAddress, port); + // Could not create channel credentials via DCA. In accordance with + // https://google.aip.dev/auth/4115, if credentials not available through + // DCA, try mTLS with credentials held by the S2A (Secure Session Agent). + if (useS2A) { + channelCredentials = createS2ASecuredChannelCredentials(); + } + if (channelCredentials != null) { + // Create the channel using S2A-secured channel credentials. + if (mtlsS2ACallCredentials != null) { + // Set {@code mtlsS2ACallCredentials} to be per-RPC call credentials, + // which will be used to fetch MTLS_S2A hard bound tokens from the metdata server. + channelCredentials = + CompositeChannelCredentials.create(channelCredentials, mtlsS2ACallCredentials); + } + // Connect to the MTLS endpoint when using S2A because S2A is used to perform an MTLS + // handshake. + builder = Grpc.newChannelBuilder(fallbackMtlsEndpoint, channelCredentials); + resolvedTarget = fallbackMtlsEndpoint; + } else { + // Use default if we cannot initialize channel credentials via DCA or S2A. + builder = ManagedChannelBuilder.forAddress(serviceAddress, port); + resolvedTarget = serviceAddress + ":" + port; + } } } } @@ -782,6 +854,7 @@ public ManagedChannelBuilder createChannelBuilder() throws IOException { // See https://github.com/googleapis/gapic-generator/issues/2816 builder.disableServiceConfigLookUp(); } + LOG.log(Level.INFO, "Channel initialized with target {0}", resolvedTarget); return builder; } @@ -864,13 +937,17 @@ private void removeApiKeyCredentialDuplicateHeaders() { * settings and a few other configurations/settings must also be valid for the request to go * through DirectPath. * - *

Checks: 1. Credentials are compatible 2.Running on Compute Engine 3. Universe Domain is - * configured to for the Google Default Universe + *

Checks: 1. Credentials are compatible 2. Running on Compute Engine (bypassed if + * attemptDirectPathXdsOverInterconnect is enabled) 3. Universe Domain is configured for the + * Google Default Universe * * @return if DirectPath is enabled for the client AND if the configurations are valid */ @InternalApi public boolean canUseDirectPath() { + if (isAttemptDirectPathXdsOverInterconnect()) { + return isDirectPathEnabled() && canUseDirectPathWithUniverseDomain(); + } return isDirectPathEnabled() && isCredentialDirectPathCompatible() && isOnComputeEngine() @@ -964,10 +1041,11 @@ public static final class Builder { private @Nullable CallCredentials mtlsS2ACallCredentials; private @Nullable ChannelPrimer channelPrimer; private ChannelPoolSettings channelPoolSettings; - private @Nullable Boolean attemptDirectPath; - private @Nullable Boolean attemptDirectPathXds; - private @Nullable Boolean allowNonDefaultServiceAccount; - private @Nullable ImmutableMap directPathServiceConfig; + @Nullable private Boolean attemptDirectPath; + @Nullable private Boolean attemptDirectPathXds; + @Nullable private Boolean attemptDirectPathXdsOverInterconnect; + @Nullable private Boolean allowNonDefaultServiceAccount; + @Nullable private ImmutableMap directPathServiceConfig; private List allowedHardBoundTokenTypes; private Builder() { @@ -999,6 +1077,7 @@ private Builder(InstantiatingGrpcChannelProvider provider) { this.channelPoolSettings = provider.channelPoolSettings; this.attemptDirectPath = provider.attemptDirectPath; this.attemptDirectPathXds = provider.attemptDirectPathXds; + this.attemptDirectPathXdsOverInterconnect = provider.attemptDirectPathXdsOverInterconnect; this.allowNonDefaultServiceAccount = provider.allowNonDefaultServiceAccount; this.allowedHardBoundTokenTypes = provider.allowedHardBoundTokenTypes; this.directPathServiceConfig = provider.directPathServiceConfig; @@ -1306,6 +1385,14 @@ public Builder setAttemptDirectPathXds() { return this; } + /** Use DirectPath xDS over Interconnect. Bypasses GCP GCE environment checks. */ + @InternalApi("For internal use by google-cloud-java clients only") + public Builder setAttemptDirectPathXdsOverInterconnect( + boolean attemptDirectPathXdsOverInterconnect) { + this.attemptDirectPathXdsOverInterconnect = attemptDirectPathXdsOverInterconnect; + return this; + } + @VisibleForTesting Builder setEnvProvider(EnvironmentProvider envProvider) { this.envProvider = envProvider; @@ -1401,7 +1488,8 @@ public InstantiatingGrpcChannelProvider build() { "DefaultMtlsProviderFactory encountered unexpected IOException: " + e.getMessage()); LOG.log( Level.WARNING, - "mTLS configuration was detected on the device, but mTLS failed to initialize. Falling back to non-mTLS channel."); + "mTLS configuration was detected on the device, but mTLS failed to initialize." + + " Falling back to non-mTLS channel."); } } } @@ -1460,11 +1548,22 @@ public Builder setChannelConfigurator( } private static void validateEndpoint(String endpoint) { + if (endpoint.contains(":///")) { + try { + java.net.URI.create(endpoint); + return; + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("invalid endpoint URI: " + endpoint, e); + } + } int colon = endpoint.lastIndexOf(':'); if (colon < 0) { - throw new IllegalArgumentException( - String.format("invalid endpoint, expecting \":\"")); + throw new IllegalArgumentException("invalid endpoint, expecting \":\""); + } + try { + Integer.parseInt(endpoint.substring(colon + 1)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("invalid endpoint, expecting \":\"", e); } - Integer.parseInt(endpoint.substring(colon + 1)); } } diff --git a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcLoggingInterceptorTest.java b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcLoggingInterceptorTest.java index fad4cd468b95..c93db599d575 100644 --- a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcLoggingInterceptorTest.java +++ b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcLoggingInterceptorTest.java @@ -32,7 +32,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -83,7 +82,7 @@ void testInterceptor_basic() { void testInterceptor_responseListener() { when(channel.newCall(Mockito.>any(), any(CallOptions.class))) .thenReturn(call); - GrpcLoggingInterceptor interceptor = spy(new GrpcLoggingInterceptor()); + GrpcLoggingInterceptor interceptor = new GrpcLoggingInterceptor(); Channel intercepted = ClientInterceptors.intercept(channel, interceptor); @SuppressWarnings("unchecked") ClientCall.Listener listener = mock(ClientCall.Listener.class); diff --git a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProviderTest.java b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProviderTest.java index c7052532955b..35a6983f5b94 100644 --- a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProviderTest.java +++ b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProviderTest.java @@ -37,6 +37,8 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; import com.google.api.core.ApiFunction; import com.google.api.gax.grpc.InstantiatingGrpcChannelProvider.Builder; @@ -86,6 +88,8 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; @@ -129,17 +133,20 @@ void testEndpoint() { } @Test - void testEndpointNoPort() { - assertThrows( - IllegalArgumentException.class, - () -> InstantiatingGrpcChannelProvider.newBuilder().setEndpoint("localhost")); + void testEndpointCustomUriScheme() { + InstantiatingGrpcChannelProvider.Builder builder = + InstantiatingGrpcChannelProvider.newBuilder() + .setEndpoint("google-c2p:///storage.googleapis.com"); + assertEquals("google-c2p:///storage.googleapis.com", builder.getEndpoint()); } - @Test - void testEndpointBadPort() { - assertThrows( - IllegalArgumentException.class, - () -> InstantiatingGrpcChannelProvider.newBuilder().setEndpoint("localhost:abcd")); + @ParameterizedTest + @ValueSource( + strings = {"google-c2p://:invalid", "google-c2p:///foo bar", "localhost", "localhost:abcd"}) + void testEndpointInvalid(String invalidEndpoint) { + InstantiatingGrpcChannelProvider.Builder builder = + InstantiatingGrpcChannelProvider.newBuilder(); + assertThrows(IllegalArgumentException.class, () -> builder.setEndpoint(invalidEndpoint)); } @Test @@ -663,7 +670,9 @@ private void createAndCloseTransportChannel(InstantiatingGrpcChannelProvider pro createAndCloseTransportChannel(provider); assertThat(logHandler.getAllMessages()) .contains( - "DirectPath is misconfigured. The DirectPath XDS option was set, but the attemptDirectPath option was not. Please set both the attemptDirectPath and attemptDirectPathXds options."); + "DirectPath is misconfigured. The DirectPath XDS option was set, but the" + + " attemptDirectPath option was not. Please set both the attemptDirectPath and" + + " attemptDirectPathXds options."); InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler); } @@ -681,8 +690,10 @@ void testLogDirectPathMisconfig_AttemptDirectPathNotSetAndAttemptDirectPathXdsSe createAndCloseTransportChannel(provider); assertThat(logHandler.getAllMessages()) .contains( - "Env var GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS was found and set to TRUE, but DirectPath was not enabled for this client. If this is intended for " - + "this client, please note that this is a misconfiguration and set the attemptDirectPath option as well."); + "Env var GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS was found and set to TRUE, but DirectPath" + + " was not enabled for this client. If this is intended for this client, please" + + " note that this is a misconfiguration and set the attemptDirectPath option as" + + " well."); InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler); } @@ -706,15 +717,19 @@ void testLogDirectPathMisconfigWrongCredential() throws Exception { FakeLogHandler logHandler = new FakeLogHandler(); InstantiatingGrpcChannelProvider.LOG.setLevel(Level.FINE); InstantiatingGrpcChannelProvider.LOG.addHandler(logHandler); + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, withSettings().withoutAnnotations()); + when(envProvider.getenv(InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH)) + .thenReturn("false"); InstantiatingGrpcChannelProvider provider = InstantiatingGrpcChannelProvider.newBuilder() .setAttemptDirectPathXds() .setAttemptDirectPath(true) - .setHeaderProvider( - mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations())) + .setHeaderProvider(mock(HeaderProvider.class, withSettings().withoutAnnotations())) .setExecutor(mock(Executor.class)) .setEndpoint(DEFAULT_ENDPOINT) .setCertificateBasedAccess(certificateBasedAccess) + .setEnvProvider(envProvider) .build(); TransportChannel transportChannel = provider.getTransportChannel(); @@ -734,16 +749,20 @@ void testLogDirectPathMisconfigNotOnGCE() throws Exception { FakeLogHandler logHandler = new FakeLogHandler(); InstantiatingGrpcChannelProvider.LOG.setLevel(Level.FINE); InstantiatingGrpcChannelProvider.LOG.addHandler(logHandler); + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, withSettings().withoutAnnotations()); + when(envProvider.getenv(InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH)) + .thenReturn("false"); InstantiatingGrpcChannelProvider provider = InstantiatingGrpcChannelProvider.newBuilder() .setAttemptDirectPathXds() .setAttemptDirectPath(true) .setAllowNonDefaultServiceAccount(true) - .setHeaderProvider( - mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations())) + .setHeaderProvider(mock(HeaderProvider.class, withSettings().withoutAnnotations())) .setExecutor(mock(Executor.class)) .setEndpoint(DEFAULT_ENDPOINT) .setCertificateBasedAccess(certificateBasedAccess) + .setEnvProvider(envProvider) .build(); TransportChannel transportChannel = provider.getTransportChannel(); @@ -923,6 +942,273 @@ public void canUseDirectPath_nonComputeCredentials() { Truth.assertThat(provider.canUseDirectPath()).isFalse(); } + @Test + public void canUseDirectPath_attemptDirectPathXdsOverInterconnect_bypassesGceCheck() { + System.setProperty("os.name", "Not Linux"); + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, withSettings().withoutAnnotations()); + when(envProvider.getenv(InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH)) + .thenReturn("false"); + Credentials credentials = mock(Credentials.class, withSettings().withoutAnnotations()); + InstantiatingGrpcChannelProvider.Builder builder = + InstantiatingGrpcChannelProvider.newBuilder() + .setCertificateBasedAccess(certificateBasedAccess) + .setAttemptDirectPath(true) + .setAttemptDirectPathXdsOverInterconnect(true) + .setCredentials(credentials) + .setEndpoint(DEFAULT_ENDPOINT) + .setEnvProvider(envProvider); + InstantiatingGrpcChannelProvider provider = + new InstantiatingGrpcChannelProvider(builder, "not-gce-product-name"); + Truth.assertThat(provider.canUseDirectPath()).isTrue(); + } + + @Test + public void + canUseDirectPath_attemptDirectPathXdsOverInterconnect_directPathDisabled_returnsFalse() { + System.setProperty("os.name", "Not Linux"); + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, withSettings().withoutAnnotations()); + when(envProvider.getenv(InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH)) + .thenReturn("false"); + Credentials credentials = mock(Credentials.class, withSettings().withoutAnnotations()); + InstantiatingGrpcChannelProvider.Builder builder = + InstantiatingGrpcChannelProvider.newBuilder() + .setCertificateBasedAccess(certificateBasedAccess) + .setAttemptDirectPath(false) + .setAttemptDirectPathXdsOverInterconnect(true) + .setCredentials(credentials) + .setEndpoint(DEFAULT_ENDPOINT) + .setEnvProvider(envProvider); + InstantiatingGrpcChannelProvider provider = + new InstantiatingGrpcChannelProvider(builder, "not-gce-product-name"); + Truth.assertThat(provider.canUseDirectPath()).isFalse(); + } + + @Test + public void + canUseDirectPath_attemptDirectPathXdsOverInterconnect_nonGDUUniverseDomain_returnsFalse() { + System.setProperty("os.name", "Not Linux"); + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, withSettings().withoutAnnotations()); + when(envProvider.getenv(InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH)) + .thenReturn("false"); + Credentials credentials = mock(Credentials.class, withSettings().withoutAnnotations()); + InstantiatingGrpcChannelProvider.Builder builder = + InstantiatingGrpcChannelProvider.newBuilder() + .setCertificateBasedAccess(certificateBasedAccess) + .setAttemptDirectPath(true) + .setAttemptDirectPathXdsOverInterconnect(true) + .setCredentials(credentials) + .setEndpoint("test.random.com:443") + .setEnvProvider(envProvider); + InstantiatingGrpcChannelProvider provider = + new InstantiatingGrpcChannelProvider(builder, "not-gce-product-name"); + Truth.assertThat(provider.canUseDirectPath()).isFalse(); + } + + @Test + public void getTransportChannel_dnsTarget_noRewrite() throws IOException, InterruptedException { + System.setProperty("os.name", "Not Linux"); + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, withSettings().withoutAnnotations()); + when(envProvider.getenv(InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH)) + .thenReturn("false"); + Credentials credentials = mock(Credentials.class, withSettings().withoutAnnotations()); + final java.util.concurrent.atomic.AtomicReference capturedTarget = + new java.util.concurrent.atomic.AtomicReference<>(); + ApiFunction channelConfigurator = + channelBuilder -> { + capturedTarget.set(extractTargetFromChannelBuilder(channelBuilder)); + return channelBuilder; + }; + + InstantiatingGrpcChannelProvider.Builder builder = + InstantiatingGrpcChannelProvider.newBuilder() + .setCertificateBasedAccess(certificateBasedAccess) + .setAttemptDirectPath(false) + .setAttemptDirectPathXdsOverInterconnect(false) + .setCredentials(credentials) + .setEndpoint("dns:///localhost:8080") + .setEnvProvider(envProvider) + .setChannelConfigurator(channelConfigurator); + + InstantiatingGrpcChannelProvider provider = + new InstantiatingGrpcChannelProvider(builder, "not-gce-product-name"); + + InstantiatingGrpcChannelProvider configuredProvider = + (InstantiatingGrpcChannelProvider) + provider + .withHeaders(Collections.emptyMap()) + .withEndpoint("dns:///localhost:8080"); + + TransportChannel transportChannel = configuredProvider.getTransportChannel(); + transportChannel.shutdownNow(); + transportChannel.awaitTermination(5, TimeUnit.SECONDS); + + Truth.assertThat(capturedTarget.get()).contains("dns:///localhost:8080"); + } + + @Test + public void getTransportChannel_storageTarget_withInterconnect() + throws IOException, InterruptedException { + System.setProperty("os.name", "Not Linux"); + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, withSettings().withoutAnnotations()); + when(envProvider.getenv(InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH)) + .thenReturn("false"); + Credentials credentials = mock(Credentials.class, withSettings().withoutAnnotations()); + final java.util.concurrent.atomic.AtomicReference capturedTarget = + new java.util.concurrent.atomic.AtomicReference<>(); + ApiFunction channelConfigurator = + channelBuilder -> { + capturedTarget.set(extractTargetFromChannelBuilder(channelBuilder)); + return channelBuilder; + }; + + InstantiatingGrpcChannelProvider.Builder builder = + InstantiatingGrpcChannelProvider.newBuilder() + .setCertificateBasedAccess(certificateBasedAccess) + .setAttemptDirectPath(true) + .setAttemptDirectPathXdsOverInterconnect(true) + .setCredentials(credentials) + .setEndpoint("storage.googleapis.com:443") + .setEnvProvider(envProvider) + .setChannelConfigurator(channelConfigurator); + + InstantiatingGrpcChannelProvider provider = + new InstantiatingGrpcChannelProvider(builder, "not-gce-product-name"); + + InstantiatingGrpcChannelProvider configuredProvider = + (InstantiatingGrpcChannelProvider) + provider + .withHeaders(Collections.emptyMap()) + .withEndpoint("storage.googleapis.com:443"); + + TransportChannel transportChannel = configuredProvider.getTransportChannel(); + transportChannel.shutdownNow(); + transportChannel.awaitTermination(5, TimeUnit.SECONDS); + + Truth.assertThat(capturedTarget.get()) + .contains("google-c2p:///storage.googleapis.com?force-xds"); + } + + @Test + public void getTransportChannel_storageTarget_withInterconnectAndNullCredentials() + throws IOException, InterruptedException { + System.setProperty("os.name", "Not Linux"); + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, withSettings().withoutAnnotations()); + when(envProvider.getenv(InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH)) + .thenReturn("false"); + final java.util.concurrent.atomic.AtomicReference capturedTarget = + new java.util.concurrent.atomic.AtomicReference<>(); + ApiFunction channelConfigurator = + channelBuilder -> { + capturedTarget.set(extractTargetFromChannelBuilder(channelBuilder)); + return channelBuilder; + }; + + InstantiatingGrpcChannelProvider.Builder builder = + InstantiatingGrpcChannelProvider.newBuilder() + .setCertificateBasedAccess(certificateBasedAccess) + .setAttemptDirectPath(true) + .setAttemptDirectPathXdsOverInterconnect(true) + .setCredentials(null) + .setEndpoint("storage.googleapis.com:443") + .setEnvProvider(envProvider) + .setChannelConfigurator(channelConfigurator); + + InstantiatingGrpcChannelProvider provider = + new InstantiatingGrpcChannelProvider(builder, "not-gce-product-name"); + + InstantiatingGrpcChannelProvider configuredProvider = + (InstantiatingGrpcChannelProvider) + provider + .withHeaders(Collections.emptyMap()) + .withEndpoint("storage.googleapis.com:443"); + + TransportChannel transportChannel = configuredProvider.getTransportChannel(); + transportChannel.shutdownNow(); + transportChannel.awaitTermination(5, TimeUnit.SECONDS); + + Truth.assertThat(capturedTarget.get()) + .contains("google-c2p:///storage.googleapis.com?force-xds"); + } + + @Test + public void getTransportChannel_customUriSchemeTarget_noRewrite() + throws IOException, InterruptedException { + System.setProperty("os.name", "Not Linux"); + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, withSettings().withoutAnnotations()); + when(envProvider.getenv(InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH)) + .thenReturn("false"); + Credentials credentials = mock(Credentials.class, withSettings().withoutAnnotations()); + final java.util.concurrent.atomic.AtomicReference capturedTarget = + new java.util.concurrent.atomic.AtomicReference<>(); + ApiFunction channelConfigurator = + channelBuilder -> { + capturedTarget.set(extractTargetFromChannelBuilder(channelBuilder)); + return channelBuilder; + }; + + InstantiatingGrpcChannelProvider.Builder builder = + InstantiatingGrpcChannelProvider.newBuilder() + .setCertificateBasedAccess(certificateBasedAccess) + .setAttemptDirectPath(false) + .setAttemptDirectPathXdsOverInterconnect(false) + .setCredentials(credentials) + .setEndpoint("google-c2p:///storage-direct.googleapis.com?force-xds") + .setEnvProvider(envProvider) + .setChannelConfigurator(channelConfigurator); + + InstantiatingGrpcChannelProvider provider = + new InstantiatingGrpcChannelProvider(builder, "not-gce-product-name"); + + InstantiatingGrpcChannelProvider configuredProvider = + (InstantiatingGrpcChannelProvider) + provider + .withHeaders(Collections.emptyMap()) + .withEndpoint("google-c2p:///storage-direct.googleapis.com?force-xds"); + + TransportChannel transportChannel = configuredProvider.getTransportChannel(); + transportChannel.shutdownNow(); + transportChannel.awaitTermination(5, TimeUnit.SECONDS); + + Truth.assertThat(capturedTarget.get()) + .contains("google-c2p:///storage-direct.googleapis.com?force-xds"); + } + + @Test + void testLogDirectPathFallbackWarning() throws Exception { + FakeLogHandler logHandler = new FakeLogHandler(); + InstantiatingGrpcChannelProvider.LOG.setLevel(Level.FINE); + InstantiatingGrpcChannelProvider.LOG.addHandler(logHandler); + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, withSettings().withoutAnnotations()); + when(envProvider.getenv(InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH)) + .thenReturn("false"); + InstantiatingGrpcChannelProvider provider = + InstantiatingGrpcChannelProvider.newBuilder() + .setAttemptDirectPath(true) + .setHeaderProvider(mock(HeaderProvider.class, withSettings().withoutAnnotations())) + .setExecutor(mock(Executor.class)) + .setEndpoint(DEFAULT_ENDPOINT) + .setCertificateBasedAccess(certificateBasedAccess) + .setEnvProvider(envProvider) + .build(); + + TransportChannel transportChannel = provider.getTransportChannel(); + + assertThat(logHandler.getAllMessages()) + .contains("DirectPath was requested but is not available. Falling back to CloudPath."); + InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler); + + transportChannel.close(); + transportChannel.awaitTermination(10, TimeUnit.SECONDS); + } + @Test public void canUseDirectPath_isNotOnComputeEngine_invalidOsNameSystemProperty() { System.setProperty("os.name", "Not Linux"); @@ -1200,7 +1486,8 @@ void createS2ASecuredChannelCredentials_bothS2AAddressesNull_returnsNull() { assertThat(provider.createS2ASecuredChannelCredentials()).isNotNull(); assertThat(logHandler.getAllMessages()) .contains( - "Cannot establish an mTLS connection to S2A because autoconfig endpoint did not return a mtls address to reach S2A."); + "Cannot establish an mTLS connection to S2A because autoconfig endpoint did not return" + + " a mtls address to reach S2A."); InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler); } @@ -1246,7 +1533,8 @@ void createS2ASecuredChannelCredentials_returnsPlaintextToS2AS2AChannelCredentia assertThat(provider.createS2ASecuredChannelCredentials()).isNotNull(); assertThat(logHandler.getAllMessages()) .contains( - "Cannot establish an mTLS connection to S2A because MTLS to MDS credentials do not exist on filesystem, falling back to plaintext connection to S2A"); + "Cannot establish an mTLS connection to S2A because MTLS to MDS credentials do not" + + " exist on filesystem, falling back to plaintext connection to S2A"); InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler); } @@ -1341,6 +1629,241 @@ void testSettingBackgroundExecutor() { assertThat(provider.getBackgroundExecutor()).isEqualTo(mockExecutor); } + private static String extractTargetFromChannelBuilder(ManagedChannelBuilder channelBuilder) { + try { + Class nettyBuilderClass = channelBuilder.getClass(); + java.lang.reflect.Field delegateField = null; + while (nettyBuilderClass != null && delegateField == null) { + try { + delegateField = nettyBuilderClass.getDeclaredField("delegate"); + } catch (NoSuchFieldException e) { + try { + delegateField = nettyBuilderClass.getDeclaredField("managedChannelImplBuilder"); + } catch (NoSuchFieldException e2) { + nettyBuilderClass = nettyBuilderClass.getSuperclass(); + } + } + } + if (delegateField != null) { + delegateField.setAccessible(true); + Object delegate = delegateField.get(channelBuilder); + Class delegateClass = delegate.getClass(); + java.lang.reflect.Field targetField = null; + while (delegateClass != null && targetField == null) { + try { + targetField = delegateClass.getDeclaredField("target"); + } catch (NoSuchFieldException e) { + delegateClass = delegateClass.getSuperclass(); + } + } + if (targetField != null) { + targetField.setAccessible(true); + return (String) targetField.get(delegate); + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + return channelBuilder.toString(); + } + + @Test + void testLogDirectPathMisconfigXdsSetDirectPathNotSet() throws Exception { + FakeLogHandler logHandler = new FakeLogHandler(); + InstantiatingGrpcChannelProvider.LOG.setLevel(Level.FINE); + InstantiatingGrpcChannelProvider.LOG.addHandler(logHandler); + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, Mockito.withSettings().withoutAnnotations()); + InstantiatingGrpcChannelProvider provider = + InstantiatingGrpcChannelProvider.newBuilder() + .setAttemptDirectPathXds() + .setAttemptDirectPath(false) + .setHeaderProvider( + mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations())) + .setExecutor(mock(Executor.class, Mockito.withSettings().withoutAnnotations())) + .setEndpoint(DEFAULT_ENDPOINT) + .setCertificateBasedAccess(certificateBasedAccess) + .setEnvProvider(envProvider) + .build(); + + try { + provider.getTransportChannel(); + } catch (Exception e) { + // ignore + } + + assertThat(logHandler.getAllMessages()) + .contains( + "DirectPath is misconfigured. The DirectPath XDS option was set, but the" + + " attemptDirectPath option was not. Please set both the attemptDirectPath and" + + " attemptDirectPathXds options."); + InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler); + } + + @Test + void testLogDirectPathMisconfigDirectPathSetXdsNotSet() throws Exception { + FakeLogHandler logHandler = new FakeLogHandler(); + InstantiatingGrpcChannelProvider.LOG.setLevel(Level.FINE); + InstantiatingGrpcChannelProvider.LOG.addHandler(logHandler); + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, Mockito.withSettings().withoutAnnotations()); + InstantiatingGrpcChannelProvider provider = + InstantiatingGrpcChannelProvider.newBuilder() + .setAttemptDirectPath(true) + .setHeaderProvider( + mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations())) + .setExecutor(mock(Executor.class, Mockito.withSettings().withoutAnnotations())) + .setEndpoint(DEFAULT_ENDPOINT) + .setCertificateBasedAccess(certificateBasedAccess) + .setEnvProvider(envProvider) + .build(); + + try { + provider.getTransportChannel(); + } catch (Exception e) { + // ignore + } + + assertThat(logHandler.getAllMessages()) + .contains( + "DirectPath is enabled, but DirectPath xDS is not. Please note that DirectPath will" + + " soon require xDS to be enabled. Please set the attemptDirectPathXds option."); + InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler); + } + + @Test + void validateEndpoint_invalidCustomUri_throws() { + InstantiatingGrpcChannelProvider.Builder builder = + InstantiatingGrpcChannelProvider.newBuilder() + .setCertificateBasedAccess(certificateBasedAccess) + .setExecutor(mock(Executor.class)) + .setHeaderProvider(mock(HeaderProvider.class, withSettings().withoutAnnotations())); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> builder.setEndpoint("google-c2p:///invalid uri with spaces")); + assertThat(exception.getMessage()).contains("invalid endpoint URI:"); + } + + @Test + void canUseDirectPath_interconnectEnabledButDirectPathDisabled_fallsBackToCloudPath() + throws Exception { + FakeLogHandler logHandler = new FakeLogHandler(); + InstantiatingGrpcChannelProvider.LOG.setLevel(Level.FINE); + InstantiatingGrpcChannelProvider.LOG.addHandler(logHandler); + + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, withSettings().withoutAnnotations()); + when(envProvider.getenv(InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH)) + .thenReturn("false"); + + InstantiatingGrpcChannelProvider provider = + InstantiatingGrpcChannelProvider.newBuilder() + .setAttemptDirectPath(false) + .setAttemptDirectPathXdsOverInterconnect(true) + .setHeaderProvider(mock(HeaderProvider.class, withSettings().withoutAnnotations())) + .setExecutor(mock(Executor.class)) + .setEndpoint(DEFAULT_ENDPOINT) + .setCertificateBasedAccess(certificateBasedAccess) + .setEnvProvider(envProvider) + .build(); + + TransportChannel transportChannel = provider.getTransportChannel(); + transportChannel.close(); + transportChannel.awaitTermination(10, TimeUnit.SECONDS); + + assertThat(logHandler.getAllMessages()) + .contains("DirectPath was requested but is not available. Falling back to CloudPath."); + InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler); + String authority = extractAuthorityFromChannelBuilder(provider.createChannelBuilder()); + Truth.assertThat(authority).isNotEqualTo("storage.googleapis.com"); + } + + @Test + void canUseDirectPath_interconnectAndDirectPathEnabledButNonGduUniverse_fallsBackToCloudPath() + throws Exception { + FakeLogHandler logHandler = new FakeLogHandler(); + InstantiatingGrpcChannelProvider.LOG.setLevel(Level.FINE); + InstantiatingGrpcChannelProvider.LOG.addHandler(logHandler); + + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, withSettings().withoutAnnotations()); + when(envProvider.getenv(InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH)) + .thenReturn("false"); + + InstantiatingGrpcChannelProvider provider = + InstantiatingGrpcChannelProvider.newBuilder() + .setAttemptDirectPath(true) + .setAttemptDirectPathXdsOverInterconnect(true) + .setHeaderProvider(mock(HeaderProvider.class, withSettings().withoutAnnotations())) + .setExecutor(mock(Executor.class)) + .setEndpoint("storage-direct.some-other-universe.com:443") + .setCertificateBasedAccess(certificateBasedAccess) + .setEnvProvider(envProvider) + .build(); + + TransportChannel transportChannel = provider.getTransportChannel(); + transportChannel.close(); + transportChannel.awaitTermination(10, TimeUnit.SECONDS); + + assertThat(logHandler.getAllMessages()) + .contains("DirectPath was requested but is not available. Falling back to CloudPath."); + InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler); + String authority = extractAuthorityFromChannelBuilder(provider.createChannelBuilder()); + Truth.assertThat(authority).isNotEqualTo("storage.googleapis.com"); + } + + @Test + void createChannelBuilder_directPathOverInterconnect_nonGcsDirectEndpoint_overridesAuthority() + throws Exception { + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, withSettings().withoutAnnotations()); + when(envProvider.getenv(InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH)) + .thenReturn("false"); + + InstantiatingGrpcChannelProvider provider = + InstantiatingGrpcChannelProvider.newBuilder() + .setAttemptDirectPath(true) + .setAttemptDirectPathXdsOverInterconnect(true) + .setHeaderProvider(mock(HeaderProvider.class, withSettings().withoutAnnotations())) + .setExecutor(mock(Executor.class)) + .setEndpoint("bigtable-direct.googleapis.com:443") + .setCertificateBasedAccess(certificateBasedAccess) + .setEnvProvider(envProvider) + .build(); + + ManagedChannelBuilder channelBuilder = provider.createChannelBuilder(); + Truth.assertThat(extractAuthorityFromChannelBuilder(channelBuilder)) + .isEqualTo("bigtable.googleapis.com"); + Truth.assertThat(extractTargetFromChannelBuilder(channelBuilder)) + .contains("google-c2p:///bigtable-direct.googleapis.com?force-xds"); + } + + @Test + void createChannelBuilder_directPathOverInterconnect_nonGcsDirectEndpoint_cloudPathFallback() + throws Exception { + EnvironmentProvider envProvider = + mock(EnvironmentProvider.class, withSettings().withoutAnnotations()); + when(envProvider.getenv(InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH)) + .thenReturn("false"); + + InstantiatingGrpcChannelProvider provider = + InstantiatingGrpcChannelProvider.newBuilder() + .setAttemptDirectPath(false) + .setAttemptDirectPathXdsOverInterconnect(true) + .setHeaderProvider(mock(HeaderProvider.class, withSettings().withoutAnnotations())) + .setExecutor(mock(Executor.class)) + .setEndpoint("bigtable-direct.googleapis.com:443") + .setCertificateBasedAccess(certificateBasedAccess) + .setEnvProvider(envProvider) + .build(); + + ManagedChannelBuilder channelBuilder = provider.createChannelBuilder(); + Truth.assertThat(extractTargetFromChannelBuilder(channelBuilder)) + .isEqualTo("bigtable.googleapis.com:443"); + } + private static class FakeLogHandler extends Handler { List records = new ArrayList<>(); @@ -1360,4 +1883,66 @@ List getAllMessages() { return records.stream().map(LogRecord::getMessage).collect(Collectors.toList()); } } + + private static String extractAuthorityFromChannelBuilder( + ManagedChannelBuilder channelBuilder) { + try { + Object current = channelBuilder; + while (current != null) { + Class clazz = current.getClass(); + java.lang.reflect.Field field = null; + while (clazz != null) { + try { + field = clazz.getDeclaredField("authority"); + break; + } catch (NoSuchFieldException e) { + try { + field = clazz.getDeclaredField("authorityOverride"); + break; + } catch (NoSuchFieldException e2) { + try { + field = clazz.getDeclaredField("overrideAuthority"); + break; + } catch (Exception ignored) { + // Expected if neither field exists on this class; continue scanning superclasses. + } + } + clazz = clazz.getSuperclass(); + } + } + if (field != null) { + field.setAccessible(true); + Object val = field.get(current); + if (val instanceof String && !((String) val).isEmpty()) { + return (String) val; + } + } + Class unwrapClass = current.getClass(); + java.lang.reflect.Field delegateField = null; + while (unwrapClass != null && delegateField == null) { + try { + delegateField = unwrapClass.getDeclaredField("delegate"); + } catch (NoSuchFieldException e) { + try { + delegateField = unwrapClass.getDeclaredField("managedChannelImplBuilder"); + } catch (NoSuchFieldException e2) { + unwrapClass = unwrapClass.getSuperclass(); + } + } + } + if (delegateField != null) { + delegateField.setAccessible(true); + Object next = delegateField.get(current); + if (next != null && next != current) { + current = next; + continue; + } + } + break; + } + return null; + } catch (Exception e) { + throw new RuntimeException(e); + } + } }