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