google.golang.org/grpc@v1.74.2/xds/internal/clients/xdsclient/test/ads_stream_ack_nack_test.go (about) 1 /* 2 * 3 * Copyright 2024 gRPC authors. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 * 17 */ 18 19 package xdsclient_test 20 21 import ( 22 "context" 23 "fmt" 24 "strings" 25 "testing" 26 "time" 27 28 "github.com/google/go-cmp/cmp" 29 "github.com/google/uuid" 30 "google.golang.org/grpc/credentials/insecure" 31 "google.golang.org/grpc/xds/internal/clients" 32 "google.golang.org/grpc/xds/internal/clients/grpctransport" 33 "google.golang.org/grpc/xds/internal/clients/internal/testutils" 34 "google.golang.org/grpc/xds/internal/clients/internal/testutils/e2e" 35 "google.golang.org/grpc/xds/internal/clients/xdsclient" 36 "google.golang.org/grpc/xds/internal/clients/xdsclient/internal/xdsresource" 37 "google.golang.org/protobuf/proto" 38 "google.golang.org/protobuf/testing/protocmp" 39 40 v3corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" 41 v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" 42 v3discoverypb "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3" 43 ) 44 45 // Creates an xDS client with the given management server address, node ID 46 // and transport builder. 47 func createXDSClient(t *testing.T, mgmtServerAddress string, nodeID string, transportBuilder clients.TransportBuilder) *xdsclient.XDSClient { 48 t.Helper() 49 50 resourceTypes := map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType} 51 si := clients.ServerIdentifier{ 52 ServerURI: mgmtServerAddress, 53 Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"}, 54 } 55 56 xdsClientConfig := xdsclient.Config{ 57 Servers: []xdsclient.ServerConfig{{ServerIdentifier: si}}, 58 Node: clients.Node{ID: nodeID, UserAgentName: "user-agent", UserAgentVersion: "0.0.0.0"}, 59 TransportBuilder: transportBuilder, 60 ResourceTypes: resourceTypes, 61 // Xdstp resource names used in this test do not specify an 62 // authority. These will end up looking up an entry with the 63 // empty key in the authorities map. Having an entry with an 64 // empty key and empty configuration, results in these 65 // resources also using the top-level configuration. 66 Authorities: map[string]xdsclient.Authority{ 67 "": {XDSServers: []xdsclient.ServerConfig{}}, 68 }, 69 } 70 71 // Create an xDS client with the above config. 72 client, err := xdsclient.New(xdsClientConfig) 73 if err != nil { 74 t.Fatalf("Failed to create xDS client: %v", err) 75 } 76 t.Cleanup(func() { client.Close() }) 77 return client 78 } 79 80 // Tests simple ACK and NACK scenarios on the ADS stream: 81 // 1. When a good response is received, i.e. once that is expected to be ACKed, 82 // the test verifies that an ACK is sent matching the version and nonce from 83 // the response. 84 // 2. When a subsequent bad response is received, i.e. once is expected to be 85 // NACKed, the test verifies that a NACK is sent matching the previously 86 // ACKed version and current nonce from the response. 87 // 3. When a subsequent good response is received, the test verifies that an 88 // ACK is sent matching the version and nonce from the current response. 89 func (s) TestADS_ACK_NACK_Simple(t *testing.T) { 90 ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout) 91 defer cancel() 92 93 // Create an xDS management server listening on a local port. Configure the 94 // request and response handlers to push on channels that are inspected by 95 // the test goroutine to verify ACK version and nonce. 96 streamRequestCh := testutils.NewChannelWithSize(1) 97 streamResponseCh := testutils.NewChannelWithSize(1) 98 mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{ 99 OnStreamRequest: func(_ int64, req *v3discoverypb.DiscoveryRequest) error { 100 streamRequestCh.SendContext(ctx, req) 101 return nil 102 }, 103 OnStreamResponse: func(_ context.Context, _ int64, _ *v3discoverypb.DiscoveryRequest, resp *v3discoverypb.DiscoveryResponse) { 104 streamResponseCh.SendContext(ctx, resp) 105 }, 106 }) 107 108 // Create a listener resource on the management server. 109 const listenerName = "listener" 110 const routeConfigName = "route-config" 111 nodeID := uuid.New().String() 112 listenerResource := e2e.DefaultClientListener(listenerName, routeConfigName) 113 resources := e2e.UpdateOptions{ 114 NodeID: nodeID, 115 Listeners: []*v3listenerpb.Listener{listenerResource}, 116 SkipValidation: true, 117 } 118 if err := mgmtServer.Update(ctx, resources); err != nil { 119 t.Fatal(err) 120 } 121 122 // Create an xDS client pointing to the above server. 123 configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}} 124 client := createXDSClient(t, mgmtServer.Address, nodeID, grpctransport.NewBuilder(configs)) 125 126 // Register a watch for a listener resource. 127 lw := newListenerWatcher() 128 ldsCancel := client.WatchResource(xdsresource.V3ListenerURL, listenerName, lw) 129 defer ldsCancel() 130 131 // Verify that the initial discovery request matches expectation. 132 r, err := streamRequestCh.Receive(ctx) 133 if err != nil { 134 t.Fatal("Timeout when waiting for the initial discovery request") 135 } 136 gotReq := r.(*v3discoverypb.DiscoveryRequest) 137 wantReq := &v3discoverypb.DiscoveryRequest{ 138 VersionInfo: "", 139 Node: &v3corepb.Node{ 140 Id: nodeID, 141 UserAgentName: "user-agent", 142 UserAgentVersionType: &v3corepb.Node_UserAgentVersion{UserAgentVersion: "0.0.0.0"}, 143 ClientFeatures: []string{"envoy.lb.does_not_support_overprovisioning", "xds.config.resource-in-sotw"}, 144 }, 145 ResourceNames: []string{listenerName}, 146 TypeUrl: "type.googleapis.com/envoy.config.listener.v3.Listener", 147 ResponseNonce: "", 148 } 149 if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" { 150 t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff) 151 } 152 153 // Capture the version and nonce from the response. 154 r, err = streamResponseCh.Receive(ctx) 155 if err != nil { 156 t.Fatal("Timeout when waiting for a discovery response from the server") 157 } 158 gotResp := r.(*v3discoverypb.DiscoveryResponse) 159 160 // Verify that the ACK contains the appropriate version and nonce. 161 r, err = streamRequestCh.Receive(ctx) 162 if err != nil { 163 t.Fatal("Timeout when waiting for ACK") 164 } 165 gotReq = r.(*v3discoverypb.DiscoveryRequest) 166 wantReq.VersionInfo = gotResp.GetVersionInfo() 167 wantReq.ResponseNonce = gotResp.GetNonce() 168 if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" { 169 t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff) 170 } 171 172 // Verify the update received by the watcher. 173 wantUpdate := listenerUpdateErrTuple{ 174 update: listenerUpdate{RouteConfigName: routeConfigName}, 175 } 176 if err := verifyListenerUpdate(ctx, lw.updateCh, wantUpdate); err != nil { 177 t.Fatal(err) 178 } 179 180 // Update the management server with a listener resource that contains an 181 // empty HTTP connection manager within the apiListener, which will cause 182 // the resource to be NACKed. 183 badListener := proto.Clone(listenerResource).(*v3listenerpb.Listener) 184 badListener.ApiListener.ApiListener = nil 185 mgmtServer.Update(ctx, e2e.UpdateOptions{ 186 NodeID: nodeID, 187 Listeners: []*v3listenerpb.Listener{badListener}, 188 SkipValidation: true, 189 }) 190 191 r, err = streamResponseCh.Receive(ctx) 192 if err != nil { 193 t.Fatal("Timeout when waiting for a discovery response from the server") 194 } 195 gotResp = r.(*v3discoverypb.DiscoveryResponse) 196 197 wantNackErr := xdsresource.NewError(xdsresource.ErrorTypeNACKed, "unexpected http connection manager resource type") 198 if err := verifyListenerUpdate(ctx, lw.ambientErrCh, listenerUpdateErrTuple{ambientErr: wantNackErr}); err != nil { 199 t.Fatal(err) 200 } 201 202 // Verify that the NACK contains the appropriate version, nonce and error. 203 // We expect the version to not change as this is a NACK. 204 r, err = streamRequestCh.Receive(ctx) 205 if err != nil { 206 t.Fatal("Timeout when waiting for NACK") 207 } 208 gotReq = r.(*v3discoverypb.DiscoveryRequest) 209 if gotNonce, wantNonce := gotReq.GetResponseNonce(), gotResp.GetNonce(); gotNonce != wantNonce { 210 t.Errorf("Unexpected nonce in discovery request, got: %v, want: %v", gotNonce, wantNonce) 211 } 212 if gotErr := gotReq.GetErrorDetail(); gotErr == nil || !strings.Contains(gotErr.GetMessage(), wantNackErr.Error()) { 213 t.Fatalf("Unexpected error in discovery request, got: %v, want: %v", gotErr.GetMessage(), wantNackErr) 214 } 215 216 // Update the management server to send a good resource again. 217 mgmtServer.Update(ctx, e2e.UpdateOptions{ 218 NodeID: nodeID, 219 Listeners: []*v3listenerpb.Listener{listenerResource}, 220 SkipValidation: true, 221 }) 222 223 // The envoy-go-control-plane management server keeps resending the same 224 // resource as long as we keep NACK'ing it. So, we will see the bad resource 225 // sent to us a few times here, before receiving the good resource. 226 var lastErr error 227 for { 228 if ctx.Err() != nil { 229 t.Fatalf("Timeout when waiting for an ACK from the xDS client. Last seen error: %v", lastErr) 230 } 231 232 r, err = streamResponseCh.Receive(ctx) 233 if err != nil { 234 t.Fatal("Timeout when waiting for a discovery response from the server") 235 } 236 gotResp = r.(*v3discoverypb.DiscoveryResponse) 237 238 // Verify that the ACK contains the appropriate version and nonce. 239 r, err = streamRequestCh.Receive(ctx) 240 if err != nil { 241 t.Fatal("Timeout when waiting for ACK") 242 } 243 gotReq = r.(*v3discoverypb.DiscoveryRequest) 244 wantReq.VersionInfo = gotResp.GetVersionInfo() 245 wantReq.ResponseNonce = gotResp.GetNonce() 246 wantReq.ErrorDetail = nil 247 diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()) 248 if diff == "" { 249 lastErr = nil 250 break 251 } 252 lastErr = fmt.Errorf("unexpected diff in discovery request, diff (-got, +want):\n%s", diff) 253 } 254 255 // Verify the update received by the watcher. 256 for ; ctx.Err() == nil; <-time.After(100 * time.Millisecond) { 257 if err := verifyListenerUpdate(ctx, lw.updateCh, wantUpdate); err != nil { 258 lastErr = err 259 continue 260 } 261 break 262 } 263 if ctx.Err() != nil { 264 t.Fatalf("Timeout when waiting for listener update. Last seen error: %v", lastErr) 265 } 266 } 267 268 // Tests the case where the first response is invalid. The test verifies that 269 // the NACK contains an empty version string. 270 func (s) TestADS_NACK_InvalidFirstResponse(t *testing.T) { 271 ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout) 272 defer cancel() 273 274 // Create an xDS management server listening on a local port. Configure the 275 // request and response handlers to push on channels that are inspected by 276 // the test goroutine to verify ACK version and nonce. 277 streamRequestCh := testutils.NewChannelWithSize(1) 278 streamResponseCh := testutils.NewChannelWithSize(1) 279 mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{ 280 OnStreamRequest: func(_ int64, req *v3discoverypb.DiscoveryRequest) error { 281 streamRequestCh.SendContext(ctx, req) 282 return nil 283 }, 284 OnStreamResponse: func(_ context.Context, _ int64, _ *v3discoverypb.DiscoveryRequest, resp *v3discoverypb.DiscoveryResponse) { 285 streamResponseCh.SendContext(ctx, resp) 286 }, 287 }) 288 289 // Create a listener resource on the management server that is expected to 290 // be NACKed by the xDS client. 291 const listenerName = "listener" 292 const routeConfigName = "route-config" 293 nodeID := uuid.New().String() 294 listenerResource := e2e.DefaultClientListener(listenerName, routeConfigName) 295 listenerResource.ApiListener.ApiListener = nil 296 resources := e2e.UpdateOptions{ 297 NodeID: nodeID, 298 Listeners: []*v3listenerpb.Listener{listenerResource}, 299 SkipValidation: true, 300 } 301 if err := mgmtServer.Update(ctx, resources); err != nil { 302 t.Fatal(err) 303 } 304 305 // Create an xDS client pointing to the above server. 306 configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}} 307 client := createXDSClient(t, mgmtServer.Address, nodeID, grpctransport.NewBuilder(configs)) 308 309 // Register a watch for a listener resource. 310 lw := newListenerWatcher() 311 ldsCancel := client.WatchResource(xdsresource.V3ListenerURL, listenerName, lw) 312 defer ldsCancel() 313 314 // Verify that the initial discovery request matches expectation. 315 r, err := streamRequestCh.Receive(ctx) 316 if err != nil { 317 t.Fatal("Timeout when waiting for the initial discovery request") 318 } 319 gotReq := r.(*v3discoverypb.DiscoveryRequest) 320 wantReq := &v3discoverypb.DiscoveryRequest{ 321 VersionInfo: "", 322 Node: &v3corepb.Node{ 323 Id: nodeID, 324 UserAgentName: "user-agent", 325 UserAgentVersionType: &v3corepb.Node_UserAgentVersion{UserAgentVersion: "0.0.0.0"}, 326 ClientFeatures: []string{"envoy.lb.does_not_support_overprovisioning", "xds.config.resource-in-sotw"}, 327 }, 328 ResourceNames: []string{listenerName}, 329 TypeUrl: "type.googleapis.com/envoy.config.listener.v3.Listener", 330 ResponseNonce: "", 331 } 332 if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" { 333 t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff) 334 } 335 336 // Capture the version and nonce from the response. 337 r, err = streamResponseCh.Receive(ctx) 338 if err != nil { 339 t.Fatal("Timeout when waiting for the discovery response from client") 340 } 341 gotResp := r.(*v3discoverypb.DiscoveryResponse) 342 343 // Verify that the error is propagated to the watcher. 344 var wantNackErr = xdsresource.NewError(xdsresource.ErrorTypeNACKed, "unexpected http connection manager resource type") 345 if err := verifyListenerUpdate(ctx, lw.resourceErrCh, listenerUpdateErrTuple{resourceErr: wantNackErr}); err != nil { 346 t.Fatal(err) 347 } 348 349 // NACK should contain the appropriate error, nonce, but empty version. 350 r, err = streamRequestCh.Receive(ctx) 351 if err != nil { 352 t.Fatal("Timeout when waiting for ACK") 353 } 354 gotReq = r.(*v3discoverypb.DiscoveryRequest) 355 if gotVersion, wantVersion := gotReq.GetVersionInfo(), ""; gotVersion != wantVersion { 356 t.Errorf("Unexpected version in discovery request, got: %v, want: %v", gotVersion, wantVersion) 357 } 358 if gotNonce, wantNonce := gotReq.GetResponseNonce(), gotResp.GetNonce(); gotNonce != wantNonce { 359 t.Errorf("Unexpected nonce in discovery request, got: %v, want: %v", gotNonce, wantNonce) 360 } 361 if gotErr := gotReq.GetErrorDetail(); gotErr == nil || !strings.Contains(gotErr.GetMessage(), wantNackErr.Error()) { 362 t.Fatalf("Unexpected error in discovery request, got: %v, want: %v", gotErr.GetMessage(), wantNackErr) 363 } 364 } 365 366 // Tests the scenario where the xDS client is no longer interested in a 367 // resource. The following sequence of events are tested: 368 // 1. A resource is requested and a good response is received. The test verifies 369 // that an ACK is sent for this resource. 370 // 2. The previously requested resource is no longer requested. The test 371 // verifies that the connection to the management server is closed. 372 // 3. The same resource is requested again. The test verifies that a new 373 // request is sent with an empty version string, which corresponds to the 374 // first request on a new connection. 375 func (s) TestADS_ACK_NACK_ResourceIsNotRequestedAnymore(t *testing.T) { 376 ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout) 377 defer cancel() 378 379 // Create an xDS management server listening on a local port. Configure the 380 // request and response handlers to push on channels that are inspected by 381 // the test goroutine to verify ACK version and nonce. 382 streamRequestCh := testutils.NewChannelWithSize(1) 383 streamResponseCh := testutils.NewChannelWithSize(1) 384 streamCloseCh := testutils.NewChannelWithSize(1) 385 mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{ 386 OnStreamRequest: func(_ int64, req *v3discoverypb.DiscoveryRequest) error { 387 streamRequestCh.SendContext(ctx, req) 388 return nil 389 }, 390 OnStreamResponse: func(_ context.Context, _ int64, _ *v3discoverypb.DiscoveryRequest, resp *v3discoverypb.DiscoveryResponse) { 391 streamResponseCh.SendContext(ctx, resp) 392 }, 393 OnStreamClosed: func(int64, *v3corepb.Node) { 394 streamCloseCh.SendContext(ctx, struct{}{}) 395 }, 396 }) 397 398 // Create a listener resource on the management server. 399 const listenerName = "listener" 400 const routeConfigName = "route-config" 401 nodeID := uuid.New().String() 402 listenerResource := e2e.DefaultClientListener(listenerName, routeConfigName) 403 resources := e2e.UpdateOptions{ 404 NodeID: nodeID, 405 Listeners: []*v3listenerpb.Listener{listenerResource}, 406 SkipValidation: true, 407 } 408 if err := mgmtServer.Update(ctx, resources); err != nil { 409 t.Fatal(err) 410 } 411 412 // Create an xDS client pointing to the above server. 413 configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}} 414 client := createXDSClient(t, mgmtServer.Address, nodeID, grpctransport.NewBuilder(configs)) 415 416 // Register a watch for a listener resource. 417 lw := newListenerWatcher() 418 ldsCancel := client.WatchResource(xdsresource.V3ListenerURL, listenerName, lw) 419 defer ldsCancel() 420 421 // Verify that the initial discovery request matches expectation. 422 r, err := streamRequestCh.Receive(ctx) 423 if err != nil { 424 t.Fatal("Timeout when waiting for the initial discovery request") 425 } 426 gotReq := r.(*v3discoverypb.DiscoveryRequest) 427 wantReq := &v3discoverypb.DiscoveryRequest{ 428 VersionInfo: "", 429 Node: &v3corepb.Node{ 430 Id: nodeID, 431 UserAgentName: "user-agent", 432 UserAgentVersionType: &v3corepb.Node_UserAgentVersion{UserAgentVersion: "0.0.0.0"}, 433 ClientFeatures: []string{"envoy.lb.does_not_support_overprovisioning", "xds.config.resource-in-sotw"}, 434 }, 435 ResourceNames: []string{listenerName}, 436 TypeUrl: "type.googleapis.com/envoy.config.listener.v3.Listener", 437 ResponseNonce: "", 438 } 439 if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" { 440 t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff) 441 } 442 443 // Capture the version and nonce from the response. 444 r, err = streamResponseCh.Receive(ctx) 445 if err != nil { 446 t.Fatal("Timeout when waiting for the discovery response from client") 447 } 448 gotResp := r.(*v3discoverypb.DiscoveryResponse) 449 450 // Verify that the ACK contains the appropriate version and nonce. 451 r, err = streamRequestCh.Receive(ctx) 452 if err != nil { 453 t.Fatal("Timeout when waiting for ACK") 454 } 455 gotReq = r.(*v3discoverypb.DiscoveryRequest) 456 wantACKReq := proto.Clone(wantReq).(*v3discoverypb.DiscoveryRequest) 457 wantACKReq.VersionInfo = gotResp.GetVersionInfo() 458 wantACKReq.ResponseNonce = gotResp.GetNonce() 459 if diff := cmp.Diff(gotReq, wantACKReq, protocmp.Transform()); diff != "" { 460 t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff) 461 } 462 463 // Verify the update received by the watcher. 464 wantUpdate := listenerUpdateErrTuple{ 465 update: listenerUpdate{RouteConfigName: routeConfigName}, 466 } 467 if err := verifyListenerUpdate(ctx, lw.updateCh, wantUpdate); err != nil { 468 t.Fatal(err) 469 } 470 471 // Cancel the watch on the listener resource. This should result in the 472 // existing connection to be management server getting closed. 473 ldsCancel() 474 if _, err := streamCloseCh.Receive(ctx); err != nil { 475 t.Fatalf("Timeout when expecting existing connection to be closed: %v", err) 476 } 477 478 // There is a race between two events when the last watch on an xdsChannel 479 // is canceled: 480 // - an empty discovery request being sent out 481 // - the ADS stream being closed 482 // To handle this race, we drain the request channel here so that if an 483 // empty discovery request was received, it is pulled out of the request 484 // channel and thereby guaranteeing a clean slate for the next watch 485 // registered below. 486 streamRequestCh.Drain() 487 488 // Register a watch for the same listener resource. 489 lw = newListenerWatcher() 490 ldsCancel = client.WatchResource(xdsresource.V3ListenerURL, listenerName, lw) 491 defer ldsCancel() 492 493 // Verify that the discovery request is identical to the first one sent out 494 // to the management server. 495 r, err = streamRequestCh.Receive(ctx) 496 if err != nil { 497 t.Fatal("Timeout when waiting for discovery request") 498 } 499 gotReq = r.(*v3discoverypb.DiscoveryRequest) 500 if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" { 501 t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff) 502 } 503 504 // Verify the update received by the watcher. 505 if err := verifyListenerUpdate(ctx, lw.updateCh, wantUpdate); err != nil { 506 t.Fatal(err) 507 } 508 }