diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da02ea831..3a31116168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ and what APIs have changed, if applicable. ## [Unreleased] +## [29.77.0] - 2025-09-18 +- Add XdsClientValidator to pre check xDS client connection + ## [29.76.0] - 2025-09-16 - Add D2 service MethodLevelProperties configuration support @@ -5900,7 +5903,8 @@ patch operations can re-use these classes for generating patch messages. ## [0.14.1] -[Unreleased]: https://github.com/linkedin/rest.li/compare/v29.76.0...master +[Unreleased]: https://github.com/linkedin/rest.li/compare/v29.77.0...master +[29.77.0]: https://github.com/linkedin/rest.li/compare/v29.76.0...v29.77.0 [29.76.0]: https://github.com/linkedin/rest.li/compare/v29.75.3...v29.76.0 [29.75.4]: https://github.com/linkedin/rest.li/compare/v29.75.3...v29.75.4 [29.75.3]: https://github.com/linkedin/rest.li/compare/v29.75.2...v29.75.3 diff --git a/build.gradle b/build.gradle index 09497833e5..9d261d1bf4 100644 --- a/build.gradle +++ b/build.gradle @@ -126,6 +126,7 @@ project.ext.externalDependency = [ 'grpcNettyShaded' : 'io.grpc:grpc-netty-shaded:1.68.3', 'grpcProtobuf' : 'io.grpc:grpc-protobuf:1.68.3', 'grpcStub' : 'io.grpc:grpc-stub:1.68.3', + 'grpcServices' : 'io.grpc:grpc-services:1.68.3', 'protoc' : 'com.google.protobuf:protoc:3.25.5', 'protobufJava' : 'com.google.protobuf:protobuf-java:3.25.5', 'protobufJavaUtil' : 'com.google.protobuf:protobuf-java-util:3.25.5', diff --git a/d2/build.gradle b/d2/build.gradle index 6496c71137..1e7232cc57 100644 --- a/d2/build.gradle +++ b/d2/build.gradle @@ -39,6 +39,7 @@ dependencies { compile externalDependency.grpcNettyShaded compile externalDependency.grpcProtobuf compile externalDependency.grpcStub + compile externalDependency.grpcServices compile externalDependency.protobufJava compile externalDependency.protobufJavaUtil compile externalDependency.envoyApi diff --git a/d2/src/main/java/com/linkedin/d2/balancer/D2ClientBuilder.java b/d2/src/main/java/com/linkedin/d2/balancer/D2ClientBuilder.java index e2613bddc1..4f972625c5 100644 --- a/d2/src/main/java/com/linkedin/d2/balancer/D2ClientBuilder.java +++ b/d2/src/main/java/com/linkedin/d2/balancer/D2ClientBuilder.java @@ -26,6 +26,7 @@ import com.linkedin.d2.balancer.clients.FailoutRedirectStrategy; import com.linkedin.d2.balancer.clients.DynamicClient; import com.linkedin.d2.balancer.clients.RequestTimeoutClient; +import javax.annotation.Nonnull; import com.linkedin.d2.balancer.clients.RetryClient; import com.linkedin.d2.balancer.clusterfailout.FailoutConfigProviderFactory; import com.linkedin.d2.balancer.dualread.DualReadStateManager; @@ -50,6 +51,7 @@ import com.linkedin.d2.discovery.stores.zk.ZooKeeper; import com.linkedin.d2.jmx.XdsServerMetricsProvider; import com.linkedin.d2.jmx.JmxManager; +import com.linkedin.d2.xds.XdsClientValidator; import com.linkedin.d2.jmx.NoOpJmxManager; import com.linkedin.r2.transport.common.TransportClientFactory; import com.linkedin.r2.transport.http.client.HttpClientFactory; @@ -236,7 +238,9 @@ public D2Client build() _config.disableDetectLiRawD2Client, _config.isLiRawD2Client, _config.xdsStreamMaxRetryBackoffSeconds, - _config.xdsChannelKeepAliveTimeMins + _config.xdsChannelKeepAliveTimeMins, + _config.xdsMinimumJavaVersion, + _config.actionOnPrecheckFailure ); final LoadBalancerWithFacilitiesFactory loadBalancerFactory = (_config.lbWithFacilitiesFactory == null) ? @@ -853,6 +857,18 @@ public D2ClientBuilder setXdsStreamMaxRetryBackoffSeconds(int xdsStreamMaxRetryB return this; } + public D2ClientBuilder setXdsMinimumJavaVersion(String xdsMinimumJavaVersion) + { + _config.xdsMinimumJavaVersion = xdsMinimumJavaVersion; + return this; + } + + public D2ClientBuilder setActionOnPrecheckFailure(XdsClientValidator.ActionOnPrecheckFailure actionOnPrecheckFailure) + { + _config.actionOnPrecheckFailure = actionOnPrecheckFailure; + return this; + } + private Map createDefaultTransportClientFactories() { final Map clientFactories = new HashMap<>(); diff --git a/d2/src/main/java/com/linkedin/d2/balancer/D2ClientConfig.java b/d2/src/main/java/com/linkedin/d2/balancer/D2ClientConfig.java index cfbc0cb4c7..126980a3b4 100644 --- a/d2/src/main/java/com/linkedin/d2/balancer/D2ClientConfig.java +++ b/d2/src/main/java/com/linkedin/d2/balancer/D2ClientConfig.java @@ -30,6 +30,7 @@ import com.linkedin.d2.balancer.util.downstreams.DownstreamServicesFetcher; import com.linkedin.d2.balancer.util.healthcheck.HealthCheckOperations; import com.linkedin.d2.balancer.util.partitions.PartitionAccessorRegistry; +import com.linkedin.d2.xds.XdsClientValidator; import com.linkedin.d2.balancer.zkfs.ZKFSTogglingLoadBalancerFactoryImpl; import com.linkedin.d2.balancer.zkfs.ZKFSTogglingLoadBalancerFactoryImpl.ComponentFactory; import com.linkedin.d2.discovery.event.LogOnlyServiceDiscoveryEventEmitter; @@ -37,6 +38,7 @@ import com.linkedin.d2.discovery.stores.zk.ZKPersistentConnection; import com.linkedin.d2.discovery.stores.zk.ZooKeeper; import com.linkedin.d2.discovery.stores.zk.ZooKeeperStore; +import com.linkedin.d2.xds.XdsClientValidator.ActionOnPrecheckFailure; import com.linkedin.d2.jmx.XdsServerMetricsProvider; import com.linkedin.d2.jmx.JmxManager; import com.linkedin.d2.jmx.NoOpXdsServerMetricsProvider; @@ -52,6 +54,8 @@ import javax.net.ssl.SSLContext; import javax.net.ssl.SSLParameters; +import static com.linkedin.d2.xds.XdsClientValidator.DEFAULT_MINIMUM_JAVA_VERSION; + public class D2ClientConfig { // default values for some configs, to be shared with other classes @@ -178,6 +182,8 @@ public class D2ClientConfig public boolean loadBalanceStreamException = false; public boolean xdsInitialResourceVersionsEnabled = false; public Integer xdsStreamMaxRetryBackoffSeconds = null; + public String xdsMinimumJavaVersion = DEFAULT_MINIMUM_JAVA_VERSION; + public XdsClientValidator.ActionOnPrecheckFailure actionOnPrecheckFailure = ActionOnPrecheckFailure.ERROR; /** * D2 client builder by default will detect if it's used to build a raw D2 client (as opposed to used by standard @@ -272,7 +278,9 @@ public D2ClientConfig() boolean disableDetectLiRawD2Client, boolean isLiRawD2Client, Integer xdsStreamMaxRetryBackoffSeconds, - Long xdsChannelKeepAliveTimeMins) + Long xdsChannelKeepAliveTimeMins, + String xdsMinimumJavaVersion, + XdsClientValidator.ActionOnPrecheckFailure actionOnPrecheckFailure) { this.zkHosts = zkHosts; this.xdsServer = xdsServer; @@ -352,5 +360,7 @@ public D2ClientConfig() this.disableDetectLiRawD2Client = disableDetectLiRawD2Client; this.isLiRawD2Client = isLiRawD2Client; this.xdsStreamMaxRetryBackoffSeconds = xdsStreamMaxRetryBackoffSeconds; + this.xdsMinimumJavaVersion = xdsMinimumJavaVersion; + this.actionOnPrecheckFailure = actionOnPrecheckFailure; } } diff --git a/d2/src/main/java/com/linkedin/d2/xds/XdsClientImpl.java b/d2/src/main/java/com/linkedin/d2/xds/XdsClientImpl.java index 8a057147d8..b05ee55d7c 100644 --- a/d2/src/main/java/com/linkedin/d2/xds/XdsClientImpl.java +++ b/d2/src/main/java/com/linkedin/d2/xds/XdsClientImpl.java @@ -55,6 +55,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.function.BiConsumer; import java.util.function.Function; @@ -111,6 +112,9 @@ public class XdsClientImpl extends XdsClient private final XdsClientJmx _xdsClientJmx; private final XdsServerMetricsProvider _serverMetricsProvider; private final boolean _initialResourceVersionsEnabled; + private final String _minimumJavaVersion; + private final XdsClientValidator.ActionOnPrecheckFailure _actionOnPrecheckFailure; + private final AtomicBoolean _started = new AtomicBoolean(); @Deprecated public XdsClientImpl(Node node, ManagedChannel managedChannel, ScheduledExecutorService executorService) @@ -168,6 +172,7 @@ public XdsClientImpl(Node node, irvSupport, null); } + @Deprecated public XdsClientImpl(Node node, ManagedChannel managedChannel, ScheduledExecutorService executorService, @@ -176,6 +181,21 @@ public XdsClientImpl(Node node, XdsServerMetricsProvider serverMetricsProvider, boolean irvSupport, Integer maxRetryBackoffSeconds) + { + this(node, managedChannel, executorService, readyTimeoutMillis, subscribeToUriGlobCollection, + serverMetricsProvider, irvSupport, maxRetryBackoffSeconds, XdsClientValidator.DEFAULT_MINIMUM_JAVA_VERSION, XdsClientValidator.DEFAULT_ACTION_ON_PRECHECK_FAILURE); + } + + public XdsClientImpl(Node node, + ManagedChannel managedChannel, + ScheduledExecutorService executorService, + long readyTimeoutMillis, + boolean subscribeToUriGlobCollection, + XdsServerMetricsProvider serverMetricsProvider, + boolean irvSupport, + Integer maxRetryBackoffSeconds, + String minimumJavaVersion, + XdsClientValidator.ActionOnPrecheckFailure actionOnPrecheckFailure) { _readyTimeoutMillis = readyTimeoutMillis; _node = node; @@ -195,6 +215,12 @@ public XdsClientImpl(Node node, _log.info("XDS initial resource versions support enabled"); } + _minimumJavaVersion = minimumJavaVersion; + _log.info("Minimum Java version required: {}", _minimumJavaVersion); + + _actionOnPrecheckFailure = actionOnPrecheckFailure; + _log.info("Action on pre-check failure: {}", _actionOnPrecheckFailure); + _retryBackoffPolicy = _backoffPolicyProvider.get(); Integer backoffSecs = (maxRetryBackoffSeconds != null && maxRetryBackoffSeconds > 0) ? maxRetryBackoffSeconds : DEFAULT_MAX_RETRY_BACKOFF_SECS; @@ -205,7 +231,13 @@ public XdsClientImpl(Node node, @Override public void start() { + if (!_started.compareAndSet(false, true)) + { + throw new IllegalStateException("Cannot start XdsClient more than once"); + } + _xdsClientJmx.setXdsClient(this); + XdsClientValidator.preCheckForIndisConnection(_managedChannel, _readyTimeoutMillis, _minimumJavaVersion, _actionOnPrecheckFailure); startRpcStream(); } diff --git a/d2/src/main/java/com/linkedin/d2/xds/XdsClientValidator.java b/d2/src/main/java/com/linkedin/d2/xds/XdsClientValidator.java new file mode 100644 index 0000000000..5456d9d5d7 --- /dev/null +++ b/d2/src/main/java/com/linkedin/d2/xds/XdsClientValidator.java @@ -0,0 +1,483 @@ +/* + Copyright (c) 2023 LinkedIn Corp. + + 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.linkedin.d2.xds; + +import io.grpc.ManagedChannel; +import java.net.InetSocketAddress; +import javax.annotation.Nullable; +import java.net.Socket; +import java.net.SocketTimeoutException; +import com.google.common.net.HostAndPort; +import io.grpc.StatusRuntimeException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Validation utilities for XDS client pre-checks. + * This class contains all the validation logic that should be performed before starting an XDS client. + */ +public class XdsClientValidator +{ + private static final Logger LOG = LoggerFactory.getLogger(XdsClientValidator.class); + + public static final String DEFAULT_MINIMUM_JAVA_VERSION = "1.8.0_282"; + public static final ActionOnPrecheckFailure DEFAULT_ACTION_ON_PRECHECK_FAILURE = ActionOnPrecheckFailure.ERROR; + + // Required class names for XDS functionality + private static final String PROTOBUF_FILE_DESCRIPTOR_CLASS = "com.google.protobuf.Descriptors$FileDescriptor"; + private static final String ENVOY_AGGREGATED_DISCOVERY_SERVICE_CLASS = "io.envoyproxy.envoy.service.discovery.v3.AggregatedDiscoveryServiceGrpc"; + + /** + * Action to take when pre-check validation fails. + */ + public enum ActionOnPrecheckFailure + { + ERROR, + // Throw exception and stop client creation + THROW + } + + /** + * Performs all pre-checks required for INDIS connection. + * This includes Java version validation, class availability checks, network connectivity tests, etc. + * + * @param managedChannel the gRPC managed channel + * @param readyTimeoutMillis timeout for connection checks in milliseconds + * @param minimumJavaVersion the minimum required Java version + * @param actionOnFailure action to take when validation fails + */ + public static void preCheckForIndisConnection(@Nullable ManagedChannel managedChannel, long readyTimeoutMillis, + String minimumJavaVersion, ActionOnPrecheckFailure actionOnFailure) + { + try + { + String errorMsg = processValidation(managedChannel, readyTimeoutMillis, minimumJavaVersion); + if (errorMsg == null) + { + LOG.info( + "[xds pre-check] All pre-checks for INDIS connection passed successfully, ready to start xDS RPC stream"); + } + else + { + handlePrecheckFailure(errorMsg, actionOnFailure); + } + } + catch (Exception e) + { + String errorMsg = "[xds pre-check] Unexpected exception during pre-checks: " + e.getMessage(); + handlePrecheckFailure(errorMsg, actionOnFailure); + } + } + + /** + * This method focuses purely on validation and returns error messages on failure. + * + * @param managedChannel the gRPC managed channel + * @param readyTimeoutMillis timeout for connection checks in milliseconds + * @param minimumJavaVersion the minimum required Java version + * @return null if all pre-checks pass, error message if validation fails + */ + @Nullable + static String processValidation(@Nullable ManagedChannel managedChannel, long readyTimeoutMillis, + String minimumJavaVersion) + { + // Check Java version + String javaVersionError = validateJavaVersion(minimumJavaVersion); + if (javaVersionError != null) + { + return javaVersionError; + } + + // Check required classes + String classAvailabilityError = + validateRequiredClasses(PROTOBUF_FILE_DESCRIPTOR_CLASS, ENVOY_AGGREGATED_DISCOVERY_SERVICE_CLASS); + if (classAvailabilityError != null) + { + return classAvailabilityError; + } + + // Check channel and authority + String authorityError = validateChannelAuthority(managedChannel); + if (authorityError != null) + { + return authorityError; + } + + // Check socket connection to rule out NACL issue + String socketError = validateSocketConnection(managedChannel.authority(), readyTimeoutMillis, new Socket()); + if (socketError != null) + { + return socketError; + } + + // Check io socket health check for channel to rule out cert/ssl issue + String healthCheckError = validateHealthCheck(managedChannel, readyTimeoutMillis); + if (healthCheckError != null) + { + return healthCheckError; + } + + return null; // All validations passed + } + + /** + * Validates Java version meets minimum requirements. + * + * @param minimumJavaVersion the minimum required Java version + * @return error message if validation fails, null if passes + */ + static String validateJavaVersion(String minimumJavaVersion) + { + String javaVersion = System.getProperty("java.version"); + String requiredVersion = minimumJavaVersion != null ? minimumJavaVersion : DEFAULT_MINIMUM_JAVA_VERSION; + LOG.info("Current Java version: {}, minimum required: {}", javaVersion, requiredVersion); + if (!meetsMinimumVersion(javaVersion, requiredVersion)) + { + return "The current Java version " + javaVersion + " is too low, please upgrade to at least " + requiredVersion + ", " + + "otherwise the service couldn't create grpc connection between service and INDIS, " + + "check go/onboardindis Guidelines #2 for more details"; + } + return null; + } + + /** + * Validates that the provided classes are available on the classpath. + * + * @param classNames fully-qualified class names to validate + * @return error message if validation fails, null if all classes are present + */ + @Nullable + static String validateRequiredClasses(String... classNames) + { + try + { + if (classNames == null) + { + return null; + } + + for (String className : classNames) + { + Class.forName(className); + LOG.info("[xds pre-check] Required class available: {}", className); + } + return null; + } + catch (ClassNotFoundException | NoClassDefFoundError e) + { + return "[xds pre-check] Required classes not found, check go/onboardindis Guidelines #3 and #4: " + e.getMessage(); + } + catch (Exception e) + { + return "[xds pre-check] Unexpected exception during class availability check: " + e.getMessage(); + } + } + + /** + * Validates channel and authority. + * + * @param managedChannel the gRPC managed channel + * @return error message if validation fails, null if passes + */ + @Nullable + static String validateChannelAuthority(@Nullable ManagedChannel managedChannel) + { + if (managedChannel == null) + { + return "[xds pre-check] Managed channel is null, cannot establish XDS connection to INDIS server"; + } + + String serverAuthority = managedChannel.authority(); + if (serverAuthority == null || serverAuthority.isEmpty()) + { + return "[xds pre-check] Cannot determine INDIS xDS server authority from managed channel, connection check " + + "failed"; + } + LOG.info("[xds pre-check] INDIS xDS server authority: {}", serverAuthority); + return null; + } + + /** + * Validates socket connection to server. + * + * @param serverAuthority the server authority string + * @param readyTimeoutMillis timeout for connection checks in milliseconds + * @return error message if validation fails, null if passes + */ + @Nullable + static String validateSocketConnection(String serverAuthority, long readyTimeoutMillis, Socket socket) + { + try + { + HostAndPort hostAndPort = HostAndPort.fromString(serverAuthority); + String host = hostAndPort.getHost(); + int port = hostAndPort.getPort(); + + LOG.info("[xds pre-check] Testing socket connection to INDIS xDS server: {}:{}", host, port); + // Check if we can connect to the server using a socket + socket.connect(new InetSocketAddress(host, port), (int) readyTimeoutMillis); + LOG.info("[xds pre-check] Successfully pinged to INDIS xDS server at {}:{}", host, port); + return null; + + } + catch (IllegalArgumentException e) + { + return "[xds pre-check] Invalid server authority format: " + serverAuthority + ", expected format: host:port or" + + " [host]:port for IPv6"; + } + catch (SocketTimeoutException e) + { + LOG.warn("[xds pre-check] Connection timeout to INDIS xDS server at authority " + serverAuthority + + " within " + readyTimeoutMillis + "ms. This may be transient - if the issue persists, " + + "check go/onboardindis Guidelines #5 and #7 for more details" + e.getMessage()); + return null; + } + catch (Exception e) + { + return "[xds pre-check] Failed to connect to INDIS xDS server at authority " + serverAuthority + + ", check go/onboardindis Guidelines #5 and #7 for more details: " + e.getMessage(); + } + } + + /** + * Validates health check for managed channel. + * + * @param managedChannel the gRPC managed channel + * @param readyTimeoutMillis timeout for health checks in milliseconds + * @return error message if validation fails, null if passes + */ + @Nullable + static String validateHealthCheck(ManagedChannel managedChannel, long readyTimeoutMillis) + { + try + { + io.grpc.health.v1.HealthGrpc.newBlockingStub(managedChannel) + .withDeadlineAfter(readyTimeoutMillis, java.util.concurrent.TimeUnit.MILLISECONDS) + .check(io.grpc.health.v1.HealthCheckRequest.newBuilder().setService("").build()); + LOG.info("[xds pre-check] Health check for managed channel passed - channel is SERVING"); + return null; + } + catch (Exception e) + { + if (e instanceof StatusRuntimeException && ((StatusRuntimeException) e).getStatus().getCode() == io.grpc.Status.Code.DEADLINE_EXCEEDED) + { + LOG.warn("[xds pre-check] Health check timeout for managed channel within " + readyTimeoutMillis + "ms. " + + "This may be transient - if the issue persists, check go/onboardindis Guidelines #2 and #9 for " + + "more details" + e.getMessage()); + return null; + } + else + { + Throwable c = e; + while (c.getCause() != null) c = c.getCause(); + return "[xds pre-check] Health check failed for managed channel: " + c.getClass().getName() + " | " + c.getMessage() + + ", check go/onboardindis Guidelines #2 and #9 for more details, " + + "if there is any other sslContext issue, please check with #pki team"; + } + } + } + + /** + * Checks if the current Java version meets the minimum required version. + * Supports all Java versions including 8, 9, 10, 11, 17, 21, etc. + * + * @param currentVersion the current Java version string (e.g., "1.8.0_172", "11.0.1", "17.0.2") + * @param minVersion the minimum required version string (e.g., "1.8.0_282", "11.0.1", "17.0.0") + * @return true if the current version meets or exceeds the minimum requirement, false otherwise + */ + public static boolean meetsMinimumVersion(String currentVersion, String minVersion) + { + if (currentVersion == null || currentVersion.trim().isEmpty() || + minVersion == null || minVersion.trim().isEmpty()) + { + LOG.warn("Invalid Java version string: current='{}', required='{}'", currentVersion, minVersion); + return false; + } + + try + { + // Check if either version is clearly invalid (contains non-numeric characters after normalization) + String normalizedCurrent = normalizeJavaVersion(currentVersion); + String normalizedMin = normalizeJavaVersion(minVersion); + + if (isInvalidVersionString(normalizedCurrent) || isInvalidVersionString(normalizedMin)) + { + LOG.warn("Invalid Java version format: current='{}', required='{}'", currentVersion, minVersion); + return false; + } + + return compareJavaVersions(normalizedCurrent, normalizedMin) >= 0; + } + catch (Exception e) + { + LOG.warn("Failed to parse Java versions. Current: {}, Min: {}", currentVersion, minVersion, e); + return false; + } + } + + /** + * Checks if a normalized version string is invalid (contains non-numeric characters). + * + * @param normalizedVersion the normalized version string + * @return true if the version string is invalid, false otherwise + */ + private static boolean isInvalidVersionString(String normalizedVersion) + { + if (normalizedVersion == null || normalizedVersion.isEmpty() || normalizedVersion.equals("0")) + { + return true; + } + + // Check if the normalized version contains only digits and dots + return !normalizedVersion.matches("^[0-9.]+$"); + } + + /** + * Compares two Java version strings. + * Returns: positive if a > b, zero if equal, negative if a < b + */ + private static int compareJavaVersions(String a, String b) + { + if (a.equals(b)) + { + return 0; + } + + int[] partsA = parseVersionParts(a); + int[] partsB = parseVersionParts(b); + + int maxLen = Math.max(partsA.length, partsB.length); + for (int i = 0; i < maxLen; i++) + { + int partA = i < partsA.length ? partsA[i] : 0; + int partB = i < partsB.length ? partsB[i] : 0; + + if (partA != partB) + { + return Integer.compare(partA, partB); + } + } + return 0; + } + + /** + * Normalizes Java version string for comparison. + * Handles common Java version formats: 1.8.0_282, 11.0.1, 17.0.2, etc. + */ + private static String normalizeJavaVersion(String version) + { + if (version == null || version.trim().isEmpty()) + { + return "0"; + } + + String s = version.trim().toLowerCase(); + + // Remove vendor prefixes + if (s.startsWith("jdk-") || s.startsWith("jre-")) + { + s = s.substring(4); + } + else if (s.startsWith("openjdk-") || s.startsWith("temurin-") || + s.startsWith("adoptopenjdk-") || s.startsWith("oracle-")) + { + s = s.substring(s.indexOf('-') + 1); + } + + // Handle legacy "1.x" format + if (s.startsWith("1.")) + { + s = s.substring(2); + } + + // Convert separators to dots + s = s.replace('_', '.').replace('+', '.'); + + // Remove prerelease tags (everything after first dash) + int dashIndex = s.indexOf('-'); + if (dashIndex >= 0) + { + s = s.substring(0, dashIndex); + } + + // Keep only digits and dots, remove other characters + s = s.replaceAll("[^0-9.]", "").replaceAll("\\.+", "."); + + return s.isEmpty() ? "0" : s; + } + + /** + * Parses version string into integer array. + * Handles version strings like "1.8.0.282" or "11.0.1". + */ + private static int[] parseVersionParts(String version) + { + if (version == null || version.isEmpty()) + { + return new int[0]; + } + + String[] parts = version.split("\\."); + int[] result = new int[parts.length]; + + for (int i = 0; i < parts.length; i++) + { + try + { + // Remove leading zeros and parse + String part = parts[i].replaceFirst("^0+(?!$)", ""); + if (part.isEmpty()) part = "0"; + + long value = Long.parseLong(part); + result[i] = (int) Math.min(value, Integer.MAX_VALUE); + } + catch (NumberFormatException e) + { + result[i] = 0; // Invalid number, treat as 0 + } + } + + return result; + } + + + /** + * Helper function to handle pre-check failure based on the configured action. + * + * @param errorMessage the error message to log or include in exception + * @param actionOnFailure the action to take on failure + * @throws IllegalStateException if actionOnFailure is THROW + */ + private static void handlePrecheckFailure(String errorMessage, ActionOnPrecheckFailure actionOnFailure) + { + switch (actionOnFailure) + { + case ERROR: + LOG.error(errorMessage); + break; + case THROW: + throw new IllegalStateException(errorMessage); + default: + throw new IllegalArgumentException("Unknown action on precheck failure: " + actionOnFailure); + } + } + + private XdsClientValidator() + { + // Utility class, no instantiation + } +} diff --git a/d2/src/main/java/com/linkedin/d2/xds/balancer/XdsLoadBalancerWithFacilitiesFactory.java b/d2/src/main/java/com/linkedin/d2/xds/balancer/XdsLoadBalancerWithFacilitiesFactory.java index 790566e349..2c8a1fdf8f 100644 --- a/d2/src/main/java/com/linkedin/d2/xds/balancer/XdsLoadBalancerWithFacilitiesFactory.java +++ b/d2/src/main/java/com/linkedin/d2/xds/balancer/XdsLoadBalancerWithFacilitiesFactory.java @@ -68,7 +68,9 @@ public LoadBalancerWithFacilities create(D2ClientConfig config) config.subscribeToUriGlobCollection, config._xdsServerMetricsProvider, config.xdsInitialResourceVersionsEnabled, - config.xdsStreamMaxRetryBackoffSeconds + config.xdsStreamMaxRetryBackoffSeconds, + config.xdsMinimumJavaVersion, + config.actionOnPrecheckFailure ); d2ClientJmxManager.registerXdsClientJmx(xdsClient.getXdsClientJmx()); diff --git a/d2/src/test/java/com/linkedin/d2/xds/TestXdsClientValidator.java b/d2/src/test/java/com/linkedin/d2/xds/TestXdsClientValidator.java new file mode 100644 index 0000000000..4816e10533 --- /dev/null +++ b/d2/src/test/java/com/linkedin/d2/xds/TestXdsClientValidator.java @@ -0,0 +1,240 @@ +package com.linkedin.d2.xds; + +import io.grpc.CallOptions; +import io.grpc.ClientCall; +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.Status; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.Assert; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.net.ConnectException; +import java.net.InetSocketAddress; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +public class TestXdsClientValidator +{ + @Mock + private ManagedChannel mockChannel; + + + @BeforeMethod + public void setUp() + { + MockitoAnnotations.initMocks(this); + } + + // ---------------- Java version comparison ---------------- + @DataProvider(name = "javaVersionComparisonTestData") + public Object[][] javaVersionComparisonTestData() + { + return new Object[][] { + // Test case name, current version, minimum version, expected result + {"exactMatch", "1.8.0_282", "1.8.0_282", true}, + {"exactMatch", "1.8.0_282", "1.8.0_282-msft", true}, + {"higherPatch", "1.8.0_312", "1.8.0_282", true}, + {"higherPatch", "11.0.2", "1.8.0_282-msft", true}, + {"lowerPatch", "1.8.0_121", "1.8.0_282", false}, + {"lowerPatch", "1.8.0_172", "1.8.0_282-msft", false}, + {"modernVersion", "11.0.2", "1.8.0_282", true}, + {"modernToModern", "17.0.1", "11.0.0", true}, + {"vendorPrefix", "jdk-17.0.3+7-LTS", "11.0.2", true}, + {"openjdkLegacy", "openjdk-8u352-b08", "1.8.0_282", true}, + {"invalidVersion", "abc", "11", false} + }; + } + + @Test(dataProvider = "javaVersionComparisonTestData") + public void testMeetsMinimumVersion(String testCaseName, String currentVersion, String minVersion, boolean expectedResult) + { + boolean result = XdsClientValidator.meetsMinimumVersion(currentVersion, minVersion); + Assert.assertEquals(result, expectedResult, + "Version comparison failed for test case: " + testCaseName + + " (current: " + currentVersion + ", min: " + minVersion + ")"); + } + + // ---------------- Required classes ---------------- + @DataProvider(name = "requiredClassesTestData") + public Object[][] requiredClassesTestData() + { + return new Object[][] { + {"xdsSpecificClasses", new String[]{ + "com.google.protobuf.Descriptors$FileDescriptor", + "io.envoyproxy.envoy.service.discovery.v3.AggregatedDiscoveryServiceGrpc" + }, null, null}, + {"nonExistentClass", new String[]{"com.example.DoesNotExistClass"}, "Required classes not found", null}, + {"emptyClasses", new String[]{}, null, null}, + {"nullClasses", null, null, null} + }; + } + + @Test(dataProvider = "requiredClassesTestData") + public void testValidateRequiredClasses(String testCaseName, String[] classes, String expectedErrorContains, String additionalCheck) + { + String error = XdsClientValidator.validateRequiredClasses(classes); + + if (expectedErrorContains == null) + { + Assert.assertNull(error, "Expected no error for test case: " + testCaseName); + } + else + { + Assert.assertNotNull(error, "Expected error for test case: " + testCaseName); + Assert.assertTrue(error.contains(expectedErrorContains), + "Expected error to contain '" + expectedErrorContains + "' but got: " + error); + } + } + + // ---------------- Channel authority ---------------- + @DataProvider(name = "channelAuthorityTestData") + public Object[][] channelAuthorityTestData() + { + return new Object[][] { + // Test case name, authority string, expected error contains (null if success) + {"nullChannel", null, "Managed channel is null"}, + {"emptyAuthority", "", "Cannot determine INDIS xDS server authority"}, + {"validAuthority", "localhost:80", null}, + }; + } + + @Test(dataProvider = "channelAuthorityTestData") + public void testValidateChannelAuthority(String testCaseName, String authority, String expectedErrorContains) + { + ManagedChannel channel = null; + if (authority != null) + { + channel = mockChannel; + when(mockChannel.authority()).thenReturn(authority); + } + + String error = XdsClientValidator.validateChannelAuthority(channel); + + if (expectedErrorContains == null) + { + Assert.assertNull(error, "Expected no error for test case: " + testCaseName); + } + else + { + Assert.assertNotNull(error, "Expected error for test case: " + testCaseName); + Assert.assertTrue(error.contains(expectedErrorContains), + "Expected error to contain '" + expectedErrorContains + "' but got: " + error); + } + } + + // ---------------- Socket connectivity ---------------- + @DataProvider(name = "socketConnectionTestData") + public Object[][] socketConnectionTestData() + { + return new Object[][] { + // Test case name, server authority, timeout, connectionBehavior, expected error contains (null if success) + {"successfulConnection", "localhost:8080", 1000L, "SUCCESS", null}, + {"successfulConnectionIPv4", "127.0.0.1:8080", 1000L, "SUCCESS", null}, + {"successfulConnectionIPv6", "[::1]:8080", 1000L, "SUCCESS", null}, + {"connectionTimeout", "localhost:8080", 1000L, "TIMEOUT", null}, // Timeout is treated as success + {"connectionRefused", "localhost:8080", 1000L, "REFUSED", "Failed to connect to INDIS xDS server"}, + {"validFormatMultipleColons", "bad:authority:with:colons", 1000L, "REFUSED", "Failed to connect to INDIS xDS server"}, + {"invalidFormatBadPort", "127.0.0.1:abc", 1000L, "INVALID_FORMAT", "Invalid server authority format"}, + {"validFormatNull", null, 1000L, "REFUSED", "Failed to connect to INDIS xDS server"}, + {"validFormatInvalidChars", "host with spaces:8080", 1000L, "REFUSED", "Failed to connect to INDIS xDS server"}, + {"validFormatEmpty", "", 1000L, "REFUSED", "Failed to connect to INDIS xDS server"}, // Empty string might be treated as valid but will fail to connect + {"validFormatNoPort", "127.0.0.1", 1000L, "REFUSED", "Failed to connect to INDIS xDS server"}, // This might be valid format but missing port + {"validFormatButBadHost", "badAuthority", 1000L, "REFUSED", "Failed to connect to INDIS xDS server"} + }; + } + + @Test(dataProvider = "socketConnectionTestData") + public void testValidateSocketConnection(String testCaseName, String serverAuthority, long timeout, String connectionBehavior, String expectedErrorContains) + { + Socket socket = spy(new Socket()); + + // Mock the socket connection behavior based on the test case + try { + switch (connectionBehavior) { + case "SUCCESS": + doNothing().when(socket).connect(any(InetSocketAddress.class), anyInt()); + break; + case "TIMEOUT": + doThrow(new SocketTimeoutException("Connection timed out")).when(socket).connect(any(InetSocketAddress.class), anyInt()); + break; + case "REFUSED": + doThrow(new ConnectException("Connection refused")).when(socket).connect(any(InetSocketAddress.class), anyInt()); + break; + case "INVALID_FORMAT": + // For invalid formats, we don't need to mock socket.connect since HostAndPort.fromString() will throw IllegalArgumentException first + break; + default: + doNothing().when(socket).connect(any(InetSocketAddress.class), anyInt()); + } + } catch (Exception e) { + // This won't happen in the mock setup + } + + String error = XdsClientValidator.validateSocketConnection(serverAuthority, timeout, socket); + + if (expectedErrorContains == null) + { + Assert.assertNull(error, "Expected no error for test case: " + testCaseName); + } + else + { + Assert.assertNotNull(error, "Expected error for test case: " + testCaseName); + Assert.assertTrue(error.contains(expectedErrorContains), + "Expected error to contain '" + expectedErrorContains + "' but got: " + error); + } + } + + // ---------------- Health check ---------------- + @DataProvider(name = "healthCheckTestData") + public Object[][] healthCheckTestData() + { + return new Object[][] { + // Test case name, status to throw, timeout, expected error contains (null if success) + {"deadlineExceeded", Status.DEADLINE_EXCEEDED, 50L, null}, + {"internalError", Status.INTERNAL.withDescription("boom"), 50L, "Health check failed for managed channel"} + }; + } + + @Test(dataProvider = "healthCheckTestData") + @SuppressWarnings("unchecked") + public void testValidateHealthCheck(String testCaseName, Status statusToThrow, long timeout, String expectedErrorContains) + { + // Mock the health check to throw the specified status + when(mockChannel.newCall(any(MethodDescriptor.class), any(CallOptions.class))) + .thenAnswer(invocation -> { + ClientCall mockCall = org.mockito.Mockito.mock(ClientCall.class); + org.mockito.Mockito.doAnswer(invocation1 -> { + ClientCall.Listener listener = (ClientCall.Listener) invocation1.getArguments()[0]; + listener.onClose(statusToThrow, new Metadata()); + return null; + }).when(mockCall).start(any(ClientCall.Listener.class), any(Metadata.class)); + return mockCall; + }); + + String error = XdsClientValidator.validateHealthCheck(mockChannel, timeout); + + if (expectedErrorContains == null) + { + Assert.assertNull(error, "Expected no error for test case: " + testCaseName); + } + else + { + Assert.assertNotNull(error, "Expected error for test case: " + testCaseName); + Assert.assertTrue(error.contains(expectedErrorContains), + "Expected error to contain '" + expectedErrorContains + "' but got: " + error); + } + } + +} diff --git a/gradle.properties b/gradle.properties index 876483d9fd..edc3f17b05 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=29.76.0 +version=29.77.0 group=com.linkedin.pegasus org.gradle.configureondemand=true org.gradle.parallel=true