diff --git a/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java b/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java index 4fa75f6b335..1d526703299 100644 --- a/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java +++ b/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java @@ -65,18 +65,22 @@ public abstract static class ServerInfo { public abstract boolean resourceTimerIsTransientError(); + public abstract boolean failOnDataErrors(); + @VisibleForTesting public static ServerInfo create(String target, @Nullable Object implSpecificConfig) { return new AutoValue_Bootstrapper_ServerInfo(target, implSpecificConfig, - false, false, false); + false, false, false, false); } @VisibleForTesting public static ServerInfo create( - String target, Object implSpecificConfig, boolean ignoreResourceDeletion, - boolean isTrustedXdsServer, boolean resourceTimerIsTransientError) { + String target, Object implSpecificConfig, + boolean ignoreResourceDeletion, boolean isTrustedXdsServer, + boolean resourceTimerIsTransientError, boolean failOnDataErrors) { return new AutoValue_Bootstrapper_ServerInfo(target, implSpecificConfig, - ignoreResourceDeletion, isTrustedXdsServer, resourceTimerIsTransientError); + ignoreResourceDeletion, isTrustedXdsServer, + resourceTimerIsTransientError, failOnDataErrors); } } diff --git a/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java b/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java index 22c794e1129..b44e32bb2d9 100644 --- a/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java +++ b/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java @@ -58,6 +58,7 @@ public abstract class BootstrapperImpl extends Bootstrapper { private static final String SERVER_FEATURE_TRUSTED_XDS_SERVER = "trusted_xds_server"; private static final String SERVER_FEATURE_RESOURCE_TIMER_IS_TRANSIENT_ERROR = "resource_timer_is_transient_error"; + private static final String SERVER_FEATURE_FAIL_ON_DATA_ERRORS = "fail_on_data_errors"; @VisibleForTesting static boolean enableXdsFallback = GrpcUtil.getFlag(GRPC_EXPERIMENTAL_XDS_FALLBACK, true); @@ -257,6 +258,7 @@ private List parseServerInfos(List rawServerConfigs, XdsLogger lo boolean resourceTimerIsTransientError = false; boolean ignoreResourceDeletion = false; + boolean failOnDataErrors = false; // "For forward compatibility reasons, the client will ignore any entry in the list that it // does not understand, regardless of type." List serverFeatures = JsonUtil.getList(serverConfig, "server_features"); @@ -267,12 +269,14 @@ private List parseServerInfos(List rawServerConfigs, XdsLogger lo } resourceTimerIsTransientError = xdsDataErrorHandlingEnabled && serverFeatures.contains(SERVER_FEATURE_RESOURCE_TIMER_IS_TRANSIENT_ERROR); + failOnDataErrors = xdsDataErrorHandlingEnabled + && serverFeatures.contains(SERVER_FEATURE_FAIL_ON_DATA_ERRORS); } servers.add( ServerInfo.create(serverUri, implSpecificConfig, ignoreResourceDeletion, serverFeatures != null && serverFeatures.contains(SERVER_FEATURE_TRUSTED_XDS_SERVER), - resourceTimerIsTransientError)); + resourceTimerIsTransientError, failOnDataErrors)); } return servers.build(); } diff --git a/xds/src/main/java/io/grpc/xds/client/XdsClientImpl.java b/xds/src/main/java/io/grpc/xds/client/XdsClientImpl.java index 2bf1286babc..0584a3dbfdd 100644 --- a/xds/src/main/java/io/grpc/xds/client/XdsClientImpl.java +++ b/xds/src/main/java/io/grpc/xds/client/XdsClientImpl.java @@ -603,16 +603,20 @@ private void handleResourceUpdate( } if (invalidResources.contains(resourceName)) { - // The resource update is invalid. Capture the error without notifying the watchers. + // The resource update is invalid (NACK). Handle as a data error. subscriber.onRejected(args.versionInfo, updateTime, errorDetail); - } - - if (invalidResources.contains(resourceName)) { - // The resource is missing. Reuse the cached resource if possible. - if (subscriber.data == null) { - // No cached data. Notify the watchers of an invalid update. - subscriber.onError(Status.UNAVAILABLE.withDescription(errorDetail), processingTracker); + + // Handle data errors (NACKs) based on fail_on_data_errors server feature. + // When xdsDataErrorHandlingEnabled is true and fail_on_data_errors is present, + // delete cached data so onError will call onResourceChanged instead of onAmbientError. + // When xdsDataErrorHandlingEnabled is false, use old behavior (always keep cached data). + if (BootstrapperImpl.xdsDataErrorHandlingEnabled && subscriber.data != null + && args.serverInfo.failOnDataErrors()) { + subscriber.data = null; } + // Call onError, which will decide whether to call onResourceChanged or onAmbientError + // based on whether data exists after the above deletion. + subscriber.onError(Status.UNAVAILABLE.withDescription(errorDetail), processingTracker); continue; } @@ -866,20 +870,42 @@ void onAbsent(@Nullable ProcessingTracker processingTracker, ServerInfo serverIn return; } - // Ignore deletion of State of the World resources when this feature is on, - // and the resource is reusable. + // Handle data errors (resource deletions) based on fail_on_data_errors server feature. + // When xdsDataErrorHandlingEnabled is true and fail_on_data_errors is not present, + // we treat deletions as ambient errors and keep using the cached resource. + // When fail_on_data_errors is present, we delete the cached resource and fail. + // When xdsDataErrorHandlingEnabled is false, use the old behavior (ignore_resource_deletion). boolean ignoreResourceDeletionEnabled = serverInfo.ignoreResourceDeletion(); - if (ignoreResourceDeletionEnabled && type.isFullStateOfTheWorld() && data != null) { - if (!resourceDeletionIgnored) { - logger.log(XdsLogLevel.FORCE_WARNING, - "xds server {0}: ignoring deletion for resource type {1} name {2}}", - serverInfo.target(), type, resource); - resourceDeletionIgnored = true; + boolean failOnDataErrors = serverInfo.failOnDataErrors(); + boolean xdsDataErrorHandlingEnabled = BootstrapperImpl.xdsDataErrorHandlingEnabled; + + if (type.isFullStateOfTheWorld() && data != null) { + // New behavior (per gRFC A88): Default is to treat deletions as ambient errors + if (xdsDataErrorHandlingEnabled && !failOnDataErrors) { + if (!resourceDeletionIgnored) { + logger.log(XdsLogLevel.FORCE_WARNING, + "xds server {0}: ignoring deletion for resource type {1} name {2}}", + serverInfo.target(), type, resource); + resourceDeletionIgnored = true; + } + Status deletionStatus = Status.NOT_FOUND.withDescription( + "Resource " + resource + " deleted from server"); + onAmbientError(deletionStatus, processingTracker); + return; + } + // Old behavior: Use ignore_resource_deletion server feature + if (!xdsDataErrorHandlingEnabled && ignoreResourceDeletionEnabled) { + if (!resourceDeletionIgnored) { + logger.log(XdsLogLevel.FORCE_WARNING, + "xds server {0}: ignoring deletion for resource type {1} name {2}}", + serverInfo.target(), type, resource); + resourceDeletionIgnored = true; + } + Status deletionStatus = Status.NOT_FOUND.withDescription( + "Resource " + resource + " deleted from server"); + onAmbientError(deletionStatus, processingTracker); + return; } - Status deletionStatus = Status.NOT_FOUND.withDescription( - "Resource " + resource + " deleted from server"); - onAmbientError(deletionStatus, processingTracker); - return; } logger.log(XdsLogLevel.INFO, "Conclude {0} resource {1} not exist", type, resource); diff --git a/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java b/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java index 2b7bd53d5ef..0a303b7255d 100644 --- a/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java +++ b/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java @@ -723,6 +723,52 @@ public void serverFeatures_ignoresUnknownValues() throws XdsInitializationExcept assertThat(serverInfo.isTrustedXdsServer()).isTrue(); } + @Test + public void serverFeature_failOnDataErrors() throws XdsInitializationException { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [\n" + + " {\"type\": \"insecure\"}\n" + + " ],\n" + + " \"server_features\": [\"fail_on_data_errors\"]\n" + + " }\n" + + " ]\n" + + "}"; + + bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); + BootstrapInfo info = bootstrapper.bootstrap(); + ServerInfo serverInfo = Iterables.getOnlyElement(info.servers()); + assertThat(serverInfo.target()).isEqualTo(SERVER_URI); + assertThat(serverInfo.implSpecificConfig()).isInstanceOf(InsecureChannelCredentials.class); + assertThat(serverInfo.failOnDataErrors()).isTrue(); + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + + @Test + public void serverFeature_failOnDataErrors_requiresEnvVar() throws XdsInitializationException { + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [\n" + + " {\"type\": \"insecure\"}\n" + + " ],\n" + + " \"server_features\": [\"fail_on_data_errors\"]\n" + + " }\n" + + " ]\n" + + "}"; + + bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); + BootstrapInfo info = bootstrapper.bootstrap(); + ServerInfo serverInfo = Iterables.getOnlyElement(info.servers()); + // Should be false when env var is not enabled + assertThat(serverInfo.failOnDataErrors()).isFalse(); + } + @Test public void notFound() { bootstrapper.bootstrapPathFromEnvVar = null; diff --git a/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java b/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java index 975570d8205..be29e5e719f 100644 --- a/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java +++ b/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java @@ -3614,7 +3614,7 @@ private static Filter buildHttpConnectionManagerFilter(HttpFilter... httpFilters private XdsResourceType.Args getXdsResourceTypeArgs(boolean isTrustedServer) { return new XdsResourceType.Args( - ServerInfo.create("http://td", "", false, isTrustedServer, false), "1.0", null, null, null, null + ServerInfo.create("http://td", "", false, isTrustedServer, false, false), "1.0", null, null, null, null ); } } diff --git a/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplTestBase.java b/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplTestBase.java index 6b9d601b2cf..cc079ffff50 100644 --- a/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplTestBase.java +++ b/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplTestBase.java @@ -362,7 +362,7 @@ public void setUp() throws IOException { cleanupRule.register(InProcessChannelBuilder.forName(serverName).directExecutor().build()); xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, ignoreResourceDeletion(), - true, false); + true, false, false); BootstrapInfo bootstrapInfo = Bootstrapper.BootstrapInfo.builder() .servers(Collections.singletonList(xdsServerInfo)) @@ -851,6 +851,52 @@ public void ldsResponseErrorHandling_subscribedResourceInvalid() { verifySubscribedResourcesMetadataSizes(3, 0, 0, 0); } + @Test + public void ldsResponseErrorHandling_subscribedResourceInvalid_withDataErrorHandlingEnabled() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + + xdsClient.watchXdsResource(XdsListenerResource.getInstance(), "A", ldsResourceWatcher); + xdsClient.watchXdsResource(XdsListenerResource.getInstance(), "B", ldsResourceWatcher); + xdsClient.watchXdsResource(XdsListenerResource.getInstance(), "C", ldsResourceWatcher); + DiscoveryRpcCall call = resourceDiscoveryCalls.poll(); + assertThat(call).isNotNull(); + verifyResourceMetadataRequested(LDS, "A"); + verifyResourceMetadataRequested(LDS, "B"); + verifyResourceMetadataRequested(LDS, "C"); + ImmutableMap resourcesV1 = ImmutableMap.of( + "A", Any.pack(mf.buildListenerWithApiListenerForRds("A", "A.1")), + "B", Any.pack(mf.buildListenerWithApiListenerForRds("B", "B.1")), + "C", Any.pack(mf.buildListenerWithApiListenerForRds("C", "C.1"))); + call.sendResponse(LDS, resourcesV1.values().asList(), VERSION_1, "0000"); + verify(ldsResourceWatcher, times(3)).onResourceChanged(any()); + ImmutableMap resourcesV2 = ImmutableMap.of( + "A", Any.pack(mf.buildListenerWithApiListenerForRds("A", "A.2")), + "B", Any.pack(mf.buildListenerWithApiListenerInvalid("B"))); + call.sendResponse(LDS, resourcesV2.values().asList(), VERSION_2, "0001"); + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); + verify(ldsResourceWatcher, times(2)).onAmbientError(statusCaptor.capture()); + List receivedStatuses = statusCaptor.getAllValues(); + assertThat(receivedStatuses).hasSize(2); + + assertThat( + receivedStatuses.stream().anyMatch( + status -> status.getCode() == Status.Code.UNAVAILABLE + && status.getDescription().contains("LDS response Listener 'B' validation error"))) + .isTrue(); + assertThat( + receivedStatuses.stream().anyMatch( + status -> status.getCode() == Status.Code.NOT_FOUND + && status.getDescription().contains("Resource C deleted from server"))) + .isTrue(); + List errorsV2 = ImmutableList.of("LDS response Listener 'B' validation error: "); + verifyResourceMetadataAcked(LDS, "A", resourcesV2.get("A"), VERSION_2, TIME_INCREMENT * 2); + verifyResourceMetadataNacked(LDS, "B", resourcesV1.get("B"), VERSION_1, TIME_INCREMENT, + VERSION_2, TIME_INCREMENT * 2, errorsV2, true); + verifyResourceMetadataAcked(LDS, "C", resourcesV1.get("C"), VERSION_1, TIME_INCREMENT); + + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + @Test public void ldsResponseErrorHandling_subscribedResourceInvalid_withRdsSubscription() { List subscribedResourceNames = ImmutableList.of("A", "B", "C"); @@ -1448,6 +1494,176 @@ public void ldsResourceDeleted_ignoreResourceDeletion() { verifyNoMoreInteractions(ldsResourceWatcher); } + /** + * When fail_on_data_errors server feature is on, xDS client should delete the cached listener + * and fail RPCs when LDS resource is deleted. + */ + @Test + public void ldsResourceDeleted_failOnDataErrors_true() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, false, + true, false, true); + BootstrapInfo bootstrapInfo = + Bootstrapper.BootstrapInfo.builder() + .servers(Collections.singletonList(xdsServerInfo)) + .node(NODE) + .authorities(ImmutableMap.of( + "", + AuthorityInfo.create( + "xdstp:///envoy.config.listener.v3.Listener/%s", + ImmutableList.of(Bootstrapper.ServerInfo.create( + SERVER_URI_EMPTY_AUTHORITY, CHANNEL_CREDENTIALS))))) + .certProviders(ImmutableMap.of()) + .build(); + xdsClient = new XdsClientImpl( + xdsTransportFactory, + bootstrapInfo, + fakeClock.getScheduledExecutorService(), + backoffPolicyProvider, + fakeClock.getStopwatchSupplier(), + timeProvider, + MessagePrinter.INSTANCE, + new TlsContextManagerImpl(bootstrapInfo), + xdsClientMetricReporter); + + InOrder inOrder = inOrder(ldsResourceWatcher); + DiscoveryRpcCall call = startResourceWatcher(XdsListenerResource.getInstance(), LDS_RESOURCE, + ldsResourceWatcher); + verifyResourceMetadataRequested(LDS, LDS_RESOURCE); + + // Initial LDS response. + call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); + call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(statusOrUpdate.getValue()); + verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerVhosts, VERSION_1, TIME_INCREMENT); + verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); + + // Empty LDS response deletes the listener and fails RPCs. + call.sendResponse(LDS, Collections.emptyList(), VERSION_2, "0001"); + call.verifyRequest(LDS, LDS_RESOURCE, VERSION_2, "0001", NODE); + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate1 = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate1.hasValue()).isFalse(); + assertThat(statusOrUpdate1.getStatus().getCode()).isEqualTo(Status.Code.NOT_FOUND); + verifyResourceMetadataDoesNotExist(LDS, LDS_RESOURCE); + verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); + + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + + /** + * When the fail_on_data_errors server feature is not present, the default behavior + * is to treat a resource deletion as an ambient error and preserve the cached resource. + */ + @Test + public void ldsResourceDeleted_failOnDataErrors_false() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + + xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, false, + true, false, false); + BootstrapInfo bootstrapInfo = + Bootstrapper.BootstrapInfo.builder() + .servers(Collections.singletonList(xdsServerInfo)) + .node(NODE) + .authorities(ImmutableMap.of( + "", + AuthorityInfo.create( + "xdstp:///envoy.config.listener.v3.Listener/%s", + ImmutableList.of(Bootstrapper.ServerInfo.create( + SERVER_URI_EMPTY_AUTHORITY, CHANNEL_CREDENTIALS))))) + .certProviders(ImmutableMap.of()) + .build(); + xdsClient = new XdsClientImpl( + xdsTransportFactory, + bootstrapInfo, + fakeClock.getScheduledExecutorService(), + backoffPolicyProvider, + fakeClock.getStopwatchSupplier(), + timeProvider, + MessagePrinter.INSTANCE, + new TlsContextManagerImpl(bootstrapInfo), + xdsClientMetricReporter); + + InOrder inOrder = inOrder(ldsResourceWatcher); + DiscoveryRpcCall call = startResourceWatcher(XdsListenerResource.getInstance(), LDS_RESOURCE, + ldsResourceWatcher); + verifyResourceMetadataRequested(LDS, LDS_RESOURCE); + + // Initial LDS response. + call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); + call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(statusOrUpdate.getValue()); + verifyResourceMetadataAcked(LDS, LDS_RESOURCE, testListenerVhosts, VERSION_1, TIME_INCREMENT); + verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); + + // Empty LDS response deletes the listener and fails RPCs. + call.sendResponse(LDS, Collections.emptyList(), VERSION_2, "0001"); + call.verifyRequest(LDS, LDS_RESOURCE, VERSION_2, "0001", NODE); + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); + inOrder.verify(ldsResourceWatcher).onAmbientError(statusCaptor.capture()); + Status receivedStatus = statusCaptor.getValue(); + assertThat(receivedStatus.getCode()).isEqualTo(Status.Code.NOT_FOUND); + assertThat(receivedStatus.getDescription()).contains( + "Resource " + LDS_RESOURCE + " deleted from server"); + inOrder.verify(ldsResourceWatcher, never()).onResourceChanged(any()); + verifySubscribedResourcesMetadataSizes(1, 0, 0, 0); + + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + + /** + * Tests that fail_on_data_errors feature is ignored if the env var is not enabled, + * and the old behavior (dropping the resource) is used. + */ + @Test + public void ldsResourceDeleted_failOnDataErrorsIgnoredWithoutEnvVar() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + + xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, false, + true, false, true); + BootstrapInfo bootstrapInfo = + Bootstrapper.BootstrapInfo.builder() + .servers(Collections.singletonList(xdsServerInfo)) + .node(NODE) + .authorities(ImmutableMap.of( + "", + AuthorityInfo.create( + "xdstp:///envoy.config.listener.v3.Listener/%s", + ImmutableList.of(Bootstrapper.ServerInfo.create( + SERVER_URI_EMPTY_AUTHORITY, CHANNEL_CREDENTIALS))))) + .certProviders(ImmutableMap.of()) + .build(); + xdsClient = new XdsClientImpl( + xdsTransportFactory, + bootstrapInfo, + fakeClock.getScheduledExecutorService(), + backoffPolicyProvider, + fakeClock.getStopwatchSupplier(), + timeProvider, + MessagePrinter.INSTANCE, + new TlsContextManagerImpl(bootstrapInfo), + xdsClientMetricReporter); + + InOrder inOrder = inOrder(ldsResourceWatcher); + DiscoveryRpcCall call = startResourceWatcher(XdsListenerResource.getInstance(), LDS_RESOURCE, + ldsResourceWatcher); + call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + assertThat(ldsUpdateCaptor.getValue().hasValue()).isTrue(); + call.sendResponse(LDS, Collections.emptyList(), VERSION_2, "0001"); + + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr statusOrUpdate = ldsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isFalse(); + assertThat(statusOrUpdate.getStatus().getCode()).isEqualTo(Status.Code.NOT_FOUND); + } + @Test @SuppressWarnings("unchecked") public void multipleLdsWatchers() { @@ -2972,6 +3188,228 @@ public void cdsResourceDeleted_ignoreResourceDeletion() { verifyNoMoreInteractions(ldsResourceWatcher); } + /** + * When fail_on_data_errors server feature is on, xDS client should delete the cached cluster + * and fail RPCs when CDS resource is deleted. + */ + @Test + public void cdsResourceDeleted_failOnDataErrors_true() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, false, + true, false, true); + BootstrapInfo bootstrapInfo = + Bootstrapper.BootstrapInfo.builder() + .servers(Collections.singletonList(xdsServerInfo)) + .node(NODE) + .authorities(ImmutableMap.of( + "", + AuthorityInfo.create( + "xdstp:///envoy.config.listener.v3.Listener/%s", + ImmutableList.of(Bootstrapper.ServerInfo.create( + SERVER_URI_EMPTY_AUTHORITY, CHANNEL_CREDENTIALS))))) + .certProviders(ImmutableMap.of()) + .build(); + xdsClient = new XdsClientImpl( + xdsTransportFactory, + bootstrapInfo, + fakeClock.getScheduledExecutorService(), + backoffPolicyProvider, + fakeClock.getStopwatchSupplier(), + timeProvider, + MessagePrinter.INSTANCE, + new TlsContextManagerImpl(bootstrapInfo), + xdsClientMetricReporter); + + DiscoveryRpcCall call = startResourceWatcher(XdsClusterResource.getInstance(), CDS_RESOURCE, + cdsResourceWatcher); + verifyResourceMetadataRequested(CDS, CDS_RESOURCE); + + // Initial CDS response. + call.sendResponse(CDS, testClusterRoundRobin, VERSION_1, "0000"); + call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); + verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenClusterRoundRobin(statusOrUpdate.getValue()); + verifyResourceMetadataAcked(CDS, CDS_RESOURCE, testClusterRoundRobin, VERSION_1, + TIME_INCREMENT); + verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); + + // Empty CDS response deletes the cluster and fails RPCs. + call.sendResponse(CDS, Collections.emptyList(), VERSION_2, "0001"); + call.verifyRequest(CDS, CDS_RESOURCE, VERSION_2, "0001", NODE); + verify(cdsResourceWatcher).onResourceChanged(argThat( + arg -> !arg.hasValue() && arg.getStatus().getDescription().contains(CDS_RESOURCE))); + verifyResourceMetadataDoesNotExist(CDS, CDS_RESOURCE); + verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); + + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + + /** + * When fail_on_data_errors server feature is on, xDS client should delete the cached cluster + * and fail RPCs when CDS resource is deleted. + */ + @Test + public void cdsResourceDeleted_failOnDataErrors_false() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + // Set failOnDataErrors to false for this test case. + xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, false, + true, false, false); + BootstrapInfo bootstrapInfo = + Bootstrapper.BootstrapInfo.builder() + .servers(Collections.singletonList(xdsServerInfo)) + .node(NODE) + .authorities(ImmutableMap.of( + "", + AuthorityInfo.create( + "xdstp:///envoy.config.listener.v3.Listener/%s", + ImmutableList.of(Bootstrapper.ServerInfo.create( + SERVER_URI_EMPTY_AUTHORITY, CHANNEL_CREDENTIALS))))) + .certProviders(ImmutableMap.of()) + .build(); + xdsClient = new XdsClientImpl( + xdsTransportFactory, + bootstrapInfo, + fakeClock.getScheduledExecutorService(), + backoffPolicyProvider, + fakeClock.getStopwatchSupplier(), + timeProvider, + MessagePrinter.INSTANCE, + new TlsContextManagerImpl(bootstrapInfo), + xdsClientMetricReporter); + + InOrder inOrder = inOrder(cdsResourceWatcher); + DiscoveryRpcCall call = startResourceWatcher(XdsClusterResource.getInstance(), CDS_RESOURCE, + cdsResourceWatcher); + verifyResourceMetadataRequested(CDS, CDS_RESOURCE); + + // Initial CDS response. + call.sendResponse(CDS, testClusterRoundRobin, VERSION_1, "0000"); + call.verifyRequest(CDS, CDS_RESOURCE, VERSION_1, "0000", NODE); + inOrder.verify(cdsResourceWatcher).onResourceChanged(cdsUpdateCaptor.capture()); + StatusOr statusOrUpdate = cdsUpdateCaptor.getValue(); + assertThat(statusOrUpdate.hasValue()).isTrue(); + verifyGoldenClusterRoundRobin(statusOrUpdate.getValue()); + verifyResourceMetadataAcked(CDS, CDS_RESOURCE, testClusterRoundRobin, VERSION_1, + TIME_INCREMENT); + verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); + + // Empty CDS response should trigger an ambient error. + call.sendResponse(CDS, Collections.emptyList(), VERSION_2, "0001"); + call.verifyRequest(CDS, CDS_RESOURCE, VERSION_2, "0001", NODE); + + // Verify that onAmbientError() is called. + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); + inOrder.verify(cdsResourceWatcher).onAmbientError(statusCaptor.capture()); + Status receivedStatus = statusCaptor.getValue(); + assertThat(receivedStatus.getCode()).isEqualTo(Status.Code.NOT_FOUND); + assertThat(receivedStatus.getDescription()).contains( + "Resource " + CDS_RESOURCE + " deleted from server"); + + // Verify that onResourceChanged() is NOT called again. + inOrder.verify(cdsResourceWatcher, never()).onResourceChanged(any()); + verifySubscribedResourcesMetadataSizes(0, 1, 0, 0); + + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + + /** + * Tests that a NACKed LDS resource update drops the cached resource when fail_on_data_errors + * is enabled. + */ + @Test + public void ldsResourceNacked_withFailOnDataErrors_dropsResource() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, false, + true, false, true); + BootstrapInfo bootstrapInfo = + Bootstrapper.BootstrapInfo.builder() + .servers(Collections.singletonList(xdsServerInfo)) + .node(NODE) + .build(); + xdsClient = new XdsClientImpl( + xdsTransportFactory, + bootstrapInfo, + fakeClock.getScheduledExecutorService(), + backoffPolicyProvider, + fakeClock.getStopwatchSupplier(), + timeProvider, + MessagePrinter.INSTANCE, + new TlsContextManagerImpl(bootstrapInfo), + xdsClientMetricReporter); + + InOrder inOrder = inOrder(ldsResourceWatcher); + DiscoveryRpcCall call = startResourceWatcher(XdsListenerResource.getInstance(), LDS_RESOURCE, + ldsResourceWatcher); + call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); + call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr initialUpdate = ldsUpdateCaptor.getValue(); + assertThat(initialUpdate.hasValue()).isTrue(); + verifyGoldenListenerVhosts(initialUpdate.getValue()); + Message invalidListener = mf.buildListenerWithApiListenerInvalid(LDS_RESOURCE); + call.sendResponse(LDS, Collections.singletonList(Any.pack(invalidListener)), VERSION_2, "0001"); + String expectedError = "LDS response Listener '" + LDS_RESOURCE + "' validation error"; + call.verifyRequestNack(LDS, LDS_RESOURCE, VERSION_1, "0001", NODE, + Collections.singletonList(expectedError)); + + inOrder.verify(ldsResourceWatcher).onResourceChanged(ldsUpdateCaptor.capture()); + StatusOr finalUpdate = ldsUpdateCaptor.getValue(); + assertThat(finalUpdate.hasValue()).isFalse(); + assertThat(finalUpdate.getStatus().getCode()).isEqualTo(Status.Code.UNAVAILABLE); + assertThat(finalUpdate.getStatus().getDescription()).contains(expectedError); + + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + + /** + * Tests that a NACKed LDS resource update is treated as an ambient error when + * fail_on_data_errors is disabled. + */ + @Test + public void ldsResourceNacked_withFailOnDataErrorsDisabled_isAmbientError() { + BootstrapperImpl.xdsDataErrorHandlingEnabled = true; + xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, false, + true, false, false); + BootstrapInfo bootstrapInfo = + Bootstrapper.BootstrapInfo.builder() + .servers(Collections.singletonList(xdsServerInfo)) + .node(NODE) + .build(); + xdsClient = new XdsClientImpl( + xdsTransportFactory, + bootstrapInfo, + fakeClock.getScheduledExecutorService(), + backoffPolicyProvider, + fakeClock.getStopwatchSupplier(), + timeProvider, + MessagePrinter.INSTANCE, + new TlsContextManagerImpl(bootstrapInfo), + xdsClientMetricReporter); + InOrder inOrder = inOrder(ldsResourceWatcher); + DiscoveryRpcCall call = startResourceWatcher(XdsListenerResource.getInstance(), LDS_RESOURCE, + ldsResourceWatcher); + + call.sendResponse(LDS, testListenerVhosts, VERSION_1, "0000"); + call.verifyRequest(LDS, LDS_RESOURCE, VERSION_1, "0000", NODE); + inOrder.verify(ldsResourceWatcher).onResourceChanged(any()); + Message invalidListener = mf.buildListenerWithApiListenerInvalid(LDS_RESOURCE); + call.sendResponse(LDS, Collections.singletonList(Any.pack(invalidListener)), VERSION_2, "0001"); + + String expectedError = "LDS response Listener '" + LDS_RESOURCE + "' validation error"; + call.verifyRequestNack(LDS, LDS_RESOURCE, VERSION_1, "0001", NODE, + Collections.singletonList(expectedError)); + ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(Status.class); + inOrder.verify(ldsResourceWatcher).onAmbientError(statusCaptor.capture()); + Status receivedStatus = statusCaptor.getValue(); + assertThat(receivedStatus.getCode()).isEqualTo(Status.Code.UNAVAILABLE); + assertThat(receivedStatus.getDescription()).contains(expectedError); + inOrder.verify(ldsResourceWatcher, never()).onResourceChanged(any()); + + BootstrapperImpl.xdsDataErrorHandlingEnabled = false; + } + @Test @SuppressWarnings("unchecked") public void multipleCdsWatchers() { @@ -3369,7 +3807,7 @@ public void flowControlAbsent() throws Exception { public void resourceTimerIsTransientError_schedulesExtendedTimeout() { BootstrapperImpl.xdsDataErrorHandlingEnabled = true; ServerInfo serverInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, - false, true, true); + false, true, true, false); BootstrapInfo bootstrapInfo = Bootstrapper.BootstrapInfo.builder() .servers(Collections.singletonList(serverInfo)) @@ -3414,7 +3852,7 @@ public void resourceTimerIsTransientError_schedulesExtendedTimeout() { public void resourceTimerIsTransientError_callsOnErrorUnavailable() { BootstrapperImpl.xdsDataErrorHandlingEnabled = true; xdsServerInfo = ServerInfo.create(SERVER_URI, CHANNEL_CREDENTIALS, ignoreResourceDeletion(), - true, true); + true, true, false); BootstrapInfo bootstrapInfo = Bootstrapper.BootstrapInfo.builder() .servers(Collections.singletonList(xdsServerInfo)) @@ -4644,7 +5082,7 @@ private XdsClientImpl createXdsClient(String serverUri) { private BootstrapInfo buildBootStrap(String serverUri) { ServerInfo xdsServerInfo = ServerInfo.create(serverUri, CHANNEL_CREDENTIALS, - ignoreResourceDeletion(), true, false); + ignoreResourceDeletion(), true, false, false); return Bootstrapper.BootstrapInfo.builder() .servers(Collections.singletonList(xdsServerInfo)) diff --git a/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java b/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java index 6bb37cd4483..45a96ee172f 100644 --- a/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java +++ b/xds/src/test/java/io/grpc/xds/XdsNameResolverTest.java @@ -358,13 +358,14 @@ public void resolving_targetAuthorityInAuthoritiesMap() { String serviceAuthority = "[::FFFF:129.144.52.38]:80"; bootstrapInfo = BootstrapInfo.builder() .servers(ImmutableList.of(ServerInfo.create( - "td.googleapis.com", InsecureChannelCredentials.create(), true, true, false))) + "td.googleapis.com", InsecureChannelCredentials.create(), true, true, false, false))) .node(Node.newBuilder().build()) .authorities( ImmutableMap.of(targetAuthority, AuthorityInfo.create( "xdstp://" + targetAuthority + "/envoy.config.listener.v3.Listener/%s?foo=1&bar=2", ImmutableList.of(ServerInfo.create( - "td.googleapis.com", InsecureChannelCredentials.create(), true, true, false))))) + "td.googleapis.com", InsecureChannelCredentials.create(), + true, true, false, false))))) .build(); expectedLdsResourceName = "xdstp://xds.authority.com/envoy.config.listener.v3.Listener/" + "%5B::FFFF:129.144.52.38%5D:80?bar=2&foo=1"; // query param canonified diff --git a/xds/src/test/java/io/grpc/xds/XdsTestUtils.java b/xds/src/test/java/io/grpc/xds/XdsTestUtils.java index becfe00c79b..f81957ee311 100644 --- a/xds/src/test/java/io/grpc/xds/XdsTestUtils.java +++ b/xds/src/test/java/io/grpc/xds/XdsTestUtils.java @@ -87,7 +87,7 @@ public class XdsTestUtils { + ".HttpConnectionManager"; static final Bootstrapper.ServerInfo EMPTY_BOOTSTRAPPER_SERVER_INFO = Bootstrapper.ServerInfo.create( - "td.googleapis.com", InsecureChannelCredentials.create(), false, true, false); + "td.googleapis.com", InsecureChannelCredentials.create(), false, true, false, false); public static final String ENDPOINT_HOSTNAME = "data-host"; public static final int ENDPOINT_PORT = 1234; diff --git a/xds/src/test/java/io/grpc/xds/client/CommonBootstrapperTestUtils.java b/xds/src/test/java/io/grpc/xds/client/CommonBootstrapperTestUtils.java index 754e903f8a9..e3760bd983f 100644 --- a/xds/src/test/java/io/grpc/xds/client/CommonBootstrapperTestUtils.java +++ b/xds/src/test/java/io/grpc/xds/client/CommonBootstrapperTestUtils.java @@ -203,7 +203,7 @@ public static Bootstrapper.BootstrapInfo buildBootStrap(List serverUris) List serverInfos = new ArrayList<>(); for (String uri : serverUris) { - serverInfos.add(ServerInfo.create(uri, CHANNEL_CREDENTIALS, false, true, false)); + serverInfos.add(ServerInfo.create(uri, CHANNEL_CREDENTIALS, false, true, false, false)); } EnvoyProtoData.Node node = EnvoyProtoData.Node.newBuilder().setId("node-id").build();