google.golang.org/grpc@v1.74.2/xds/internal/xdsclient/tests/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" 31 "google.golang.org/grpc/internal/testutils" 32 "google.golang.org/grpc/internal/testutils/xds/e2e" 33 "google.golang.org/grpc/internal/xds/bootstrap" 34 "google.golang.org/grpc/xds/internal/xdsclient" 35 "google.golang.org/grpc/xds/internal/xdsclient/xdsresource" 36 "google.golang.org/protobuf/proto" 37 "google.golang.org/protobuf/testing/protocmp" 38 39 v3corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" 40 v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" 41 v3discoverypb "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3" 42 ) 43 44 // Creates an xDS client with the given bootstrap contents. 45 func createXDSClient(t *testing.T, bootstrapContents []byte) xdsclient.XDSClient { 46 t.Helper() 47 48 config, err := bootstrap.NewConfigFromContents(bootstrapContents) 49 if err != nil { 50 t.Fatalf("Failed to parse bootstrap contents: %s, %v", string(bootstrapContents), err) 51 } 52 pool := xdsclient.NewPool(config) 53 client, close, err := pool.NewClientForTesting(xdsclient.OptionsForTesting{ 54 Name: t.Name(), 55 }) 56 if err != nil { 57 t.Fatalf("Failed to create xDS client: %v", err) 58 } 59 t.Cleanup(close) 60 return client 61 } 62 63 // Tests simple ACK and NACK scenarios on the ADS stream: 64 // 1. When a good response is received, i.e. once that is expected to be ACKed, 65 // the test verifies that an ACK is sent matching the version and nonce from 66 // the response. 67 // 2. When a subsequent bad response is received, i.e. once is expected to be 68 // NACKed, the test verifies that a NACK is sent matching the previously 69 // ACKed version and current nonce from the response. 70 // 3. When a subsequent good response is received, the test verifies that an 71 // ACK is sent matching the version and nonce from the current response. 72 func (s) TestADS_ACK_NACK_Simple(t *testing.T) { 73 ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout) 74 defer cancel() 75 76 // Create an xDS management server listening on a local port. Configure the 77 // request and response handlers to push on channels that are inspected by 78 // the test goroutine to verify ACK version and nonce. 79 streamRequestCh := testutils.NewChannel() 80 streamResponseCh := testutils.NewChannel() 81 mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{ 82 OnStreamRequest: func(_ int64, req *v3discoverypb.DiscoveryRequest) error { 83 streamRequestCh.SendContext(ctx, req) 84 return nil 85 }, 86 OnStreamResponse: func(_ context.Context, _ int64, _ *v3discoverypb.DiscoveryRequest, resp *v3discoverypb.DiscoveryResponse) { 87 streamResponseCh.SendContext(ctx, resp) 88 }, 89 }) 90 91 // Create a listener resource on the management server. 92 const listenerName = "listener" 93 const routeConfigName = "route-config" 94 nodeID := uuid.New().String() 95 listenerResource := e2e.DefaultClientListener(listenerName, routeConfigName) 96 resources := e2e.UpdateOptions{ 97 NodeID: nodeID, 98 Listeners: []*v3listenerpb.Listener{listenerResource}, 99 SkipValidation: true, 100 } 101 if err := mgmtServer.Update(ctx, resources); err != nil { 102 t.Fatal(err) 103 } 104 105 // Create an xDS client with bootstrap pointing to the above server. 106 bc := e2e.DefaultBootstrapContents(t, nodeID, mgmtServer.Address) 107 client := createXDSClient(t, bc) 108 109 // Register a watch for a listener resource. 110 lw := newListenerWatcher() 111 ldsCancel := xdsresource.WatchListener(client, listenerName, lw) 112 defer ldsCancel() 113 114 // Verify that the initial discovery request matches expectation. 115 r, err := streamRequestCh.Receive(ctx) 116 if err != nil { 117 t.Fatal("Timeout when waiting for the initial discovery request") 118 } 119 gotReq := r.(*v3discoverypb.DiscoveryRequest) 120 wantReq := &v3discoverypb.DiscoveryRequest{ 121 VersionInfo: "", 122 Node: &v3corepb.Node{ 123 Id: nodeID, 124 UserAgentName: "gRPC Go", 125 UserAgentVersionType: &v3corepb.Node_UserAgentVersion{UserAgentVersion: grpc.Version}, 126 ClientFeatures: []string{"envoy.lb.does_not_support_overprovisioning", "xds.config.resource-in-sotw"}, 127 }, 128 ResourceNames: []string{listenerName}, 129 TypeUrl: "type.googleapis.com/envoy.config.listener.v3.Listener", 130 ResponseNonce: "", 131 } 132 if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" { 133 t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff) 134 } 135 136 // Capture the version and nonce from the response. 137 r, err = streamResponseCh.Receive(ctx) 138 if err != nil { 139 t.Fatal("Timeout when waiting for a discovery response from the server") 140 } 141 gotResp := r.(*v3discoverypb.DiscoveryResponse) 142 143 // Verify that the ACK contains the appropriate version and nonce. 144 r, err = streamRequestCh.Receive(ctx) 145 if err != nil { 146 t.Fatal("Timeout when waiting for ACK") 147 } 148 gotReq = r.(*v3discoverypb.DiscoveryRequest) 149 wantReq.VersionInfo = gotResp.GetVersionInfo() 150 wantReq.ResponseNonce = gotResp.GetNonce() 151 if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" { 152 t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff) 153 } 154 155 // Verify the update received by the watcher. 156 wantUpdate := listenerUpdateErrTuple{ 157 update: xdsresource.ListenerUpdate{ 158 RouteConfigName: routeConfigName, 159 HTTPFilters: []xdsresource.HTTPFilter{{Name: "router"}}, 160 }, 161 } 162 if err := verifyListenerUpdate(ctx, lw.updateCh, wantUpdate); err != nil { 163 t.Fatal(err) 164 } 165 166 // Update the management server with a listener resource that contains an 167 // empty HTTP connection manager within the apiListener, which will cause 168 // the resource to be NACKed. 169 badListener := proto.Clone(listenerResource).(*v3listenerpb.Listener) 170 badListener.ApiListener.ApiListener = nil 171 mgmtServer.Update(ctx, e2e.UpdateOptions{ 172 NodeID: nodeID, 173 Listeners: []*v3listenerpb.Listener{badListener}, 174 SkipValidation: true, 175 }) 176 177 r, err = streamResponseCh.Receive(ctx) 178 if err != nil { 179 t.Fatal("Timeout when waiting for a discovery response from the server") 180 } 181 gotResp = r.(*v3discoverypb.DiscoveryResponse) 182 183 wantNackErr := xdsresource.NewError(xdsresource.ErrorTypeNACKed, "unexpected http connection manager resource type") 184 if err := verifyListenerUpdate(ctx, lw.updateCh, listenerUpdateErrTuple{err: wantNackErr}); err != nil { 185 t.Fatal(err) 186 } 187 188 // Verify that the NACK contains the appropriate version, nonce and error. 189 // We expect the version to not change as this is a NACK. 190 r, err = streamRequestCh.Receive(ctx) 191 if err != nil { 192 t.Fatal("Timeout when waiting for NACK") 193 } 194 gotReq = r.(*v3discoverypb.DiscoveryRequest) 195 if gotNonce, wantNonce := gotReq.GetResponseNonce(), gotResp.GetNonce(); gotNonce != wantNonce { 196 t.Errorf("Unexpected nonce in discovery request, got: %v, want: %v", gotNonce, wantNonce) 197 } 198 if gotErr := gotReq.GetErrorDetail(); gotErr == nil || !strings.Contains(gotErr.GetMessage(), wantNackErr.Error()) { 199 t.Fatalf("Unexpected error in discovery request, got: %v, want: %v", gotErr.GetMessage(), wantNackErr) 200 } 201 202 // Update the management server to send a good resource again. 203 mgmtServer.Update(ctx, e2e.UpdateOptions{ 204 NodeID: nodeID, 205 Listeners: []*v3listenerpb.Listener{listenerResource}, 206 SkipValidation: true, 207 }) 208 209 // The envoy-go-control-plane management server keeps resending the same 210 // resource as long as we keep NACK'ing it. So, we will see the bad resource 211 // sent to us a few times here, before receiving the good resource. 212 var lastErr error 213 for { 214 if ctx.Err() != nil { 215 t.Fatalf("Timeout when waiting for an ACK from the xDS client. Last seen error: %v", lastErr) 216 } 217 218 r, err = streamResponseCh.Receive(ctx) 219 if err != nil { 220 t.Fatal("Timeout when waiting for a discovery response from the server") 221 } 222 gotResp = r.(*v3discoverypb.DiscoveryResponse) 223 224 // Verify that the ACK contains the appropriate version and nonce. 225 r, err = streamRequestCh.Receive(ctx) 226 if err != nil { 227 t.Fatal("Timeout when waiting for ACK") 228 } 229 gotReq = r.(*v3discoverypb.DiscoveryRequest) 230 wantReq.VersionInfo = gotResp.GetVersionInfo() 231 wantReq.ResponseNonce = gotResp.GetNonce() 232 wantReq.ErrorDetail = nil 233 diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()) 234 if diff == "" { 235 lastErr = nil 236 break 237 } 238 lastErr = fmt.Errorf("unexpected diff in discovery request, diff (-got, +want):\n%s", diff) 239 } 240 241 // Verify the update received by the watcher. 242 for ; ctx.Err() == nil; <-time.After(100 * time.Millisecond) { 243 if err := verifyListenerUpdate(ctx, lw.updateCh, wantUpdate); err != nil { 244 lastErr = err 245 continue 246 } 247 break 248 } 249 if ctx.Err() != nil { 250 t.Fatalf("Timeout when waiting for listener update. Last seen error: %v", lastErr) 251 } 252 } 253 254 // Tests the case where the first response is invalid. The test verifies that 255 // the NACK contains an empty version string. 256 func (s) TestADS_NACK_InvalidFirstResponse(t *testing.T) { 257 ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout) 258 defer cancel() 259 260 // Create an xDS management server listening on a local port. Configure the 261 // request and response handlers to push on channels that are inspected by 262 // the test goroutine to verify ACK version and nonce. 263 streamRequestCh := testutils.NewChannel() 264 streamResponseCh := testutils.NewChannel() 265 mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{ 266 OnStreamRequest: func(_ int64, req *v3discoverypb.DiscoveryRequest) error { 267 streamRequestCh.SendContext(ctx, req) 268 return nil 269 }, 270 OnStreamResponse: func(_ context.Context, _ int64, _ *v3discoverypb.DiscoveryRequest, resp *v3discoverypb.DiscoveryResponse) { 271 streamResponseCh.SendContext(ctx, resp) 272 }, 273 }) 274 275 // Create a listener resource on the management server that is expected to 276 // be NACKed by the xDS client. 277 const listenerName = "listener" 278 const routeConfigName = "route-config" 279 nodeID := uuid.New().String() 280 listenerResource := e2e.DefaultClientListener(listenerName, routeConfigName) 281 listenerResource.ApiListener.ApiListener = nil 282 resources := e2e.UpdateOptions{ 283 NodeID: nodeID, 284 Listeners: []*v3listenerpb.Listener{listenerResource}, 285 SkipValidation: true, 286 } 287 if err := mgmtServer.Update(ctx, resources); err != nil { 288 t.Fatal(err) 289 } 290 291 // Create an xDS client with bootstrap pointing to the above server. 292 bc := e2e.DefaultBootstrapContents(t, nodeID, mgmtServer.Address) 293 client := createXDSClient(t, bc) 294 295 // Register a watch for a listener resource. 296 lw := newListenerWatcher() 297 ldsCancel := xdsresource.WatchListener(client, listenerName, lw) 298 defer ldsCancel() 299 300 // Verify that the initial discovery request matches expectation. 301 r, err := streamRequestCh.Receive(ctx) 302 if err != nil { 303 t.Fatal("Timeout when waiting for the initial discovery request") 304 } 305 gotReq := r.(*v3discoverypb.DiscoveryRequest) 306 wantReq := &v3discoverypb.DiscoveryRequest{ 307 VersionInfo: "", 308 Node: &v3corepb.Node{ 309 Id: nodeID, 310 UserAgentName: "gRPC Go", 311 UserAgentVersionType: &v3corepb.Node_UserAgentVersion{UserAgentVersion: grpc.Version}, 312 ClientFeatures: []string{"envoy.lb.does_not_support_overprovisioning", "xds.config.resource-in-sotw"}, 313 }, 314 ResourceNames: []string{listenerName}, 315 TypeUrl: "type.googleapis.com/envoy.config.listener.v3.Listener", 316 ResponseNonce: "", 317 } 318 if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" { 319 t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff) 320 } 321 322 // Capture the version and nonce from the response. 323 r, err = streamResponseCh.Receive(ctx) 324 if err != nil { 325 t.Fatal("Timeout when waiting for the discovery response from client") 326 } 327 gotResp := r.(*v3discoverypb.DiscoveryResponse) 328 329 // Verify that the error is propagated to the watcher. 330 var wantNackErr = xdsresource.NewError(xdsresource.ErrorTypeNACKed, "unexpected http connection manager resource type") 331 if err := verifyListenerUpdate(ctx, lw.updateCh, listenerUpdateErrTuple{err: wantNackErr}); err != nil { 332 t.Fatal(err) 333 } 334 335 // NACK should contain the appropriate error, nonce, but empty version. 336 r, err = streamRequestCh.Receive(ctx) 337 if err != nil { 338 t.Fatal("Timeout when waiting for ACK") 339 } 340 gotReq = r.(*v3discoverypb.DiscoveryRequest) 341 if gotVersion, wantVersion := gotReq.GetVersionInfo(), ""; gotVersion != wantVersion { 342 t.Errorf("Unexpected version in discovery request, got: %v, want: %v", gotVersion, wantVersion) 343 } 344 if gotNonce, wantNonce := gotReq.GetResponseNonce(), gotResp.GetNonce(); gotNonce != wantNonce { 345 t.Errorf("Unexpected nonce in discovery request, got: %v, want: %v", gotNonce, wantNonce) 346 } 347 if gotErr := gotReq.GetErrorDetail(); gotErr == nil || !strings.Contains(gotErr.GetMessage(), wantNackErr.Error()) { 348 t.Fatalf("Unexpected error in discovery request, got: %v, want: %v", gotErr.GetMessage(), wantNackErr) 349 } 350 } 351 352 // Tests the scenario where the xDS client is no longer interested in a 353 // resource. The following sequence of events are tested: 354 // 1. A resource is requested and a good response is received. The test verifies 355 // that an ACK is sent for this resource. 356 // 2. The previously requested resource is no longer requested. The test 357 // verifies that the connection to the management server is closed. 358 // 3. The same resource is requested again. The test verifies that a new 359 // request is sent with an empty version string, which corresponds to the 360 // first request on a new connection. 361 func (s) TestADS_ACK_NACK_ResourceIsNotRequestedAnymore(t *testing.T) { 362 ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout) 363 defer cancel() 364 365 // Create an xDS management server listening on a local port. Configure the 366 // request and response handlers to push on channels that are inspected by 367 // the test goroutine to verify ACK version and nonce. 368 streamRequestCh := testutils.NewChannel() 369 streamResponseCh := testutils.NewChannel() 370 streamCloseCh := testutils.NewChannel() 371 mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{ 372 OnStreamRequest: func(_ int64, req *v3discoverypb.DiscoveryRequest) error { 373 streamRequestCh.SendContext(ctx, req) 374 return nil 375 }, 376 OnStreamResponse: func(_ context.Context, _ int64, _ *v3discoverypb.DiscoveryRequest, resp *v3discoverypb.DiscoveryResponse) { 377 streamResponseCh.SendContext(ctx, resp) 378 }, 379 OnStreamClosed: func(int64, *v3corepb.Node) { 380 streamCloseCh.SendContext(ctx, struct{}{}) 381 }, 382 }) 383 384 // Create a listener resource on the management server. 385 const listenerName = "listener" 386 const routeConfigName = "route-config" 387 nodeID := uuid.New().String() 388 listenerResource := e2e.DefaultClientListener(listenerName, routeConfigName) 389 resources := e2e.UpdateOptions{ 390 NodeID: nodeID, 391 Listeners: []*v3listenerpb.Listener{listenerResource}, 392 SkipValidation: true, 393 } 394 if err := mgmtServer.Update(ctx, resources); err != nil { 395 t.Fatal(err) 396 } 397 398 // Create an xDS client with bootstrap pointing to the above server. 399 bc := e2e.DefaultBootstrapContents(t, nodeID, mgmtServer.Address) 400 config, err := bootstrap.NewConfigFromContents(bc) 401 if err != nil { 402 t.Fatalf("Failed to parse bootstrap contents: %s, %v", string(bc), err) 403 } 404 pool := xdsclient.NewPool(config) 405 client, close, err := pool.NewClientForTesting(xdsclient.OptionsForTesting{ 406 Name: t.Name(), 407 }) 408 if err != nil { 409 t.Fatalf("Failed to create xDS client: %v", err) 410 } 411 defer close() 412 413 // Register a watch for a listener resource. 414 lw := newListenerWatcher() 415 ldsCancel := xdsresource.WatchListener(client, listenerName, lw) 416 defer ldsCancel() 417 418 // Verify that the initial discovery request matches expectation. 419 r, err := streamRequestCh.Receive(ctx) 420 if err != nil { 421 t.Fatal("Timeout when waiting for the initial discovery request") 422 } 423 gotReq := r.(*v3discoverypb.DiscoveryRequest) 424 wantReq := &v3discoverypb.DiscoveryRequest{ 425 VersionInfo: "", 426 Node: &v3corepb.Node{ 427 Id: nodeID, 428 UserAgentName: "gRPC Go", 429 UserAgentVersionType: &v3corepb.Node_UserAgentVersion{UserAgentVersion: grpc.Version}, 430 ClientFeatures: []string{"envoy.lb.does_not_support_overprovisioning", "xds.config.resource-in-sotw"}, 431 }, 432 ResourceNames: []string{listenerName}, 433 TypeUrl: "type.googleapis.com/envoy.config.listener.v3.Listener", 434 ResponseNonce: "", 435 } 436 if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" { 437 t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff) 438 } 439 440 // Capture the version and nonce from the response. 441 r, err = streamResponseCh.Receive(ctx) 442 if err != nil { 443 t.Fatal("Timeout when waiting for the discovery response from client") 444 } 445 gotResp := r.(*v3discoverypb.DiscoveryResponse) 446 447 // Verify that the ACK contains the appropriate version and nonce. 448 r, err = streamRequestCh.Receive(ctx) 449 if err != nil { 450 t.Fatal("Timeout when waiting for ACK") 451 } 452 gotReq = r.(*v3discoverypb.DiscoveryRequest) 453 wantACKReq := proto.Clone(wantReq).(*v3discoverypb.DiscoveryRequest) 454 wantACKReq.VersionInfo = gotResp.GetVersionInfo() 455 wantACKReq.ResponseNonce = gotResp.GetNonce() 456 if diff := cmp.Diff(gotReq, wantACKReq, protocmp.Transform()); diff != "" { 457 t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff) 458 } 459 460 // Verify the update received by the watcher. 461 wantUpdate := listenerUpdateErrTuple{ 462 update: xdsresource.ListenerUpdate{ 463 RouteConfigName: routeConfigName, 464 HTTPFilters: []xdsresource.HTTPFilter{{Name: "router"}}, 465 }, 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 = xdsresource.WatchListener(client, 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 }