snapshot of https://github.com/grpc/grpc-go.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
codecov / upload (push) Failing after 13m59s
CodeQL / Analyze (push) Failing after 1s
govulncheck / govulncheck (.) (push) Failing after 1s
govulncheck / govulncheck (./cmd/protoc-gen-go-grpc) (push) Failing after 1s
govulncheck / govulncheck (./gcp/observability) (push) Failing after 1s
govulncheck / govulncheck (./security/advancedtls) (push) Failing after 1s
govulncheck / govulncheck (./stats/opencensus) (push) Failing after 1s
Testing / tests (arm, latest) (push) Waiting to run
Testing / vet-proto (push) Failing after 1s
Testing / extras (latest) (push) Failing after 1s
Testing / static checks (latest-1) (push) Failing after 1s
Testing / tests (-race, latest) (push) Failing after 1s
Testing / tests (i386, latest) (push) Failing after 1s
Testing / tests (latest) (push) Failing after 1s
Testing / tests (latest-1) (push) Failing after 1s
Lock Threads / lock (push) Failing after 1s
Stale bot / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 20:40:15 +05:00
commit e241b33f26
1365 changed files with 339570 additions and 0 deletions

View File

@@ -0,0 +1,671 @@
/*
*
* Copyright 2024 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package xdsclient_test
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/internal/xds/clients"
"google.golang.org/grpc/internal/xds/clients/grpctransport"
"google.golang.org/grpc/internal/xds/clients/internal/testutils"
"google.golang.org/grpc/internal/xds/clients/internal/testutils/e2e"
"google.golang.org/grpc/internal/xds/clients/xdsclient"
"google.golang.org/grpc/internal/xds/clients/xdsclient/internal/xdsresource"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/testing/protocmp"
v3corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
v3discoverypb "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3"
)
// Creates an xDS client with the given management server address, node ID
// and transport builder.
func createXDSClient(t *testing.T, mgmtServerAddress string, nodeID string, transportBuilder clients.TransportBuilder) *xdsclient.XDSClient {
t.Helper()
resourceTypes := map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType}
si := clients.ServerIdentifier{
ServerURI: mgmtServerAddress,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{ServerIdentifier: si}},
Node: clients.Node{ID: nodeID, UserAgentName: "user-agent", UserAgentVersion: "0.0.0.0"},
TransportBuilder: transportBuilder,
ResourceTypes: resourceTypes,
// Xdstp resource names used in this test do not specify an
// authority. These will end up looking up an entry with the
// empty key in the authorities map. Having an entry with an
// empty key and empty configuration, results in these
// resources also using the top-level configuration.
Authorities: map[string]xdsclient.Authority{
"": {XDSServers: []xdsclient.ServerConfig{}},
},
}
// Create an xDS client with the above config.
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
t.Cleanup(func() { client.Close() })
return client
}
// Tests simple ACK and NACK scenarios on the ADS stream:
// 1. When a good response is received, i.e. once that is expected to be ACKed,
// the test verifies that an ACK is sent matching the version and nonce from
// the response.
// 2. When a subsequent bad response is received, i.e. once is expected to be
// NACKed, the test verifies that a NACK is sent matching the previously
// ACKed version and current nonce from the response.
// 3. When a subsequent good response is received, the test verifies that an
// ACK is sent matching the version and nonce from the current response.
func (s) TestADS_ACK_NACK_Simple(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Create an xDS management server listening on a local port. Configure the
// request and response handlers to push on channels that are inspected by
// the test goroutine to verify ACK version and nonce.
streamRequestCh := testutils.NewChannelWithSize(1)
streamResponseCh := testutils.NewChannelWithSize(1)
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{
OnStreamRequest: func(_ int64, req *v3discoverypb.DiscoveryRequest) error {
streamRequestCh.SendContext(ctx, req)
return nil
},
OnStreamResponse: func(_ context.Context, _ int64, _ *v3discoverypb.DiscoveryRequest, resp *v3discoverypb.DiscoveryResponse) {
streamResponseCh.SendContext(ctx, resp)
},
})
// Create a listener resource on the management server.
const listenerName = "listener"
const routeConfigName = "route-config"
nodeID := uuid.New().String()
listenerResource := e2e.DefaultClientListener(listenerName, routeConfigName)
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{listenerResource},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}
// Create an xDS client pointing to the above server.
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
client := createXDSClient(t, mgmtServer.Address, nodeID, grpctransport.NewBuilder(configs))
// Register a watch for a listener resource.
lw := newListenerWatcher()
ldsCancel := client.WatchResource(xdsresource.V3ListenerURL, listenerName, lw)
defer ldsCancel()
// Verify that the initial discovery request matches expectation.
r, err := streamRequestCh.Receive(ctx)
if err != nil {
t.Fatal("Timeout when waiting for the initial discovery request")
}
gotReq := r.(*v3discoverypb.DiscoveryRequest)
wantReq := &v3discoverypb.DiscoveryRequest{
VersionInfo: "",
Node: &v3corepb.Node{
Id: nodeID,
UserAgentName: "user-agent",
UserAgentVersionType: &v3corepb.Node_UserAgentVersion{UserAgentVersion: "0.0.0.0"},
ClientFeatures: []string{"envoy.lb.does_not_support_overprovisioning", "xds.config.resource-in-sotw"},
},
ResourceNames: []string{listenerName},
TypeUrl: "type.googleapis.com/envoy.config.listener.v3.Listener",
ResponseNonce: "",
}
if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" {
t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff)
}
// Capture the version and nonce from the response.
r, err = streamResponseCh.Receive(ctx)
if err != nil {
t.Fatal("Timeout when waiting for a discovery response from the server")
}
gotResp := r.(*v3discoverypb.DiscoveryResponse)
// Verify that the ACK contains the appropriate version and nonce.
r, err = streamRequestCh.Receive(ctx)
if err != nil {
t.Fatal("Timeout when waiting for ACK")
}
gotReq = r.(*v3discoverypb.DiscoveryRequest)
wantReq.VersionInfo = gotResp.GetVersionInfo()
wantReq.ResponseNonce = gotResp.GetNonce()
if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" {
t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff)
}
// Verify the update received by the watcher.
wantUpdate := listenerUpdateErrTuple{
update: listenerUpdate{RouteConfigName: routeConfigName},
}
if err := verifyListenerUpdate(ctx, lw.updateCh, wantUpdate); err != nil {
t.Fatal(err)
}
// Update the management server with a listener resource that contains an
// empty HTTP connection manager within the apiListener, which will cause
// the resource to be NACKed.
badListener := proto.Clone(listenerResource).(*v3listenerpb.Listener)
badListener.ApiListener.ApiListener = nil
mgmtServer.Update(ctx, e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{badListener},
SkipValidation: true,
})
r, err = streamResponseCh.Receive(ctx)
if err != nil {
t.Fatal("Timeout when waiting for a discovery response from the server")
}
gotResp = r.(*v3discoverypb.DiscoveryResponse)
wantNackErr := xdsresource.NewError(xdsresource.ErrorTypeNACKed, "unexpected http connection manager resource type")
if err := verifyListenerUpdate(ctx, lw.ambientErrCh, listenerUpdateErrTuple{ambientErr: wantNackErr}); err != nil {
t.Fatal(err)
}
// Verify that the NACK contains the appropriate version, nonce and error.
// We expect the version to not change as this is a NACK.
r, err = streamRequestCh.Receive(ctx)
if err != nil {
t.Fatal("Timeout when waiting for NACK")
}
gotReq = r.(*v3discoverypb.DiscoveryRequest)
if gotNonce, wantNonce := gotReq.GetResponseNonce(), gotResp.GetNonce(); gotNonce != wantNonce {
t.Errorf("Unexpected nonce in discovery request, got: %v, want: %v", gotNonce, wantNonce)
}
if gotErr := gotReq.GetErrorDetail(); gotErr == nil || !strings.Contains(gotErr.GetMessage(), wantNackErr.Error()) {
t.Fatalf("Unexpected error in discovery request, got: %v, want: %v", gotErr.GetMessage(), wantNackErr)
}
// Update the management server to send a good resource again.
mgmtServer.Update(ctx, e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{listenerResource},
SkipValidation: true,
})
// The envoy-go-control-plane management server keeps resending the same
// resource as long as we keep NACK'ing it. So, we will see the bad resource
// sent to us a few times here, before receiving the good resource.
var lastErr error
for {
if ctx.Err() != nil {
t.Fatalf("Timeout when waiting for an ACK from the xDS client. Last seen error: %v", lastErr)
}
r, err = streamResponseCh.Receive(ctx)
if err != nil {
t.Fatal("Timeout when waiting for a discovery response from the server")
}
gotResp = r.(*v3discoverypb.DiscoveryResponse)
// Verify that the ACK contains the appropriate version and nonce.
r, err = streamRequestCh.Receive(ctx)
if err != nil {
t.Fatal("Timeout when waiting for ACK")
}
gotReq = r.(*v3discoverypb.DiscoveryRequest)
wantReq.VersionInfo = gotResp.GetVersionInfo()
wantReq.ResponseNonce = gotResp.GetNonce()
wantReq.ErrorDetail = nil
diff := cmp.Diff(gotReq, wantReq, protocmp.Transform())
if diff == "" {
lastErr = nil
break
}
lastErr = fmt.Errorf("unexpected diff in discovery request, diff (-got, +want):\n%s", diff)
}
// Verify the update received by the watcher.
for ; ctx.Err() == nil; <-time.After(100 * time.Millisecond) {
if err := verifyListenerUpdate(ctx, lw.updateCh, wantUpdate); err != nil {
lastErr = err
continue
}
break
}
if ctx.Err() != nil {
t.Fatalf("Timeout when waiting for listener update. Last seen error: %v", lastErr)
}
}
// Tests the case where the first response is invalid. The test verifies that
// the NACK contains an empty version string.
func (s) TestADS_NACK_InvalidFirstResponse(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Create an xDS management server listening on a local port. Configure the
// request and response handlers to push on channels that are inspected by
// the test goroutine to verify ACK version and nonce.
streamRequestCh := testutils.NewChannelWithSize(1)
streamResponseCh := testutils.NewChannelWithSize(1)
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{
OnStreamRequest: func(_ int64, req *v3discoverypb.DiscoveryRequest) error {
streamRequestCh.SendContext(ctx, req)
return nil
},
OnStreamResponse: func(_ context.Context, _ int64, _ *v3discoverypb.DiscoveryRequest, resp *v3discoverypb.DiscoveryResponse) {
streamResponseCh.SendContext(ctx, resp)
},
})
// Create a listener resource on the management server that is expected to
// be NACKed by the xDS client.
const listenerName = "listener"
const routeConfigName = "route-config"
nodeID := uuid.New().String()
listenerResource := e2e.DefaultClientListener(listenerName, routeConfigName)
listenerResource.ApiListener.ApiListener = nil
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{listenerResource},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}
// Create an xDS client pointing to the above server.
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
client := createXDSClient(t, mgmtServer.Address, nodeID, grpctransport.NewBuilder(configs))
// Register a watch for a listener resource.
lw := newListenerWatcher()
ldsCancel := client.WatchResource(xdsresource.V3ListenerURL, listenerName, lw)
defer ldsCancel()
// Verify that the initial discovery request matches expectation.
r, err := streamRequestCh.Receive(ctx)
if err != nil {
t.Fatal("Timeout when waiting for the initial discovery request")
}
gotReq := r.(*v3discoverypb.DiscoveryRequest)
wantReq := &v3discoverypb.DiscoveryRequest{
VersionInfo: "",
Node: &v3corepb.Node{
Id: nodeID,
UserAgentName: "user-agent",
UserAgentVersionType: &v3corepb.Node_UserAgentVersion{UserAgentVersion: "0.0.0.0"},
ClientFeatures: []string{"envoy.lb.does_not_support_overprovisioning", "xds.config.resource-in-sotw"},
},
ResourceNames: []string{listenerName},
TypeUrl: "type.googleapis.com/envoy.config.listener.v3.Listener",
ResponseNonce: "",
}
if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" {
t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff)
}
// Capture the version and nonce from the response.
r, err = streamResponseCh.Receive(ctx)
if err != nil {
t.Fatal("Timeout when waiting for the discovery response from client")
}
gotResp := r.(*v3discoverypb.DiscoveryResponse)
// Verify that the error is propagated to the watcher.
var wantNackErr = xdsresource.NewError(xdsresource.ErrorTypeNACKed, "unexpected http connection manager resource type")
if err := verifyListenerUpdate(ctx, lw.resourceErrCh, listenerUpdateErrTuple{resourceErr: wantNackErr}); err != nil {
t.Fatal(err)
}
// NACK should contain the appropriate error, nonce, but empty version.
r, err = streamRequestCh.Receive(ctx)
if err != nil {
t.Fatal("Timeout when waiting for ACK")
}
gotReq = r.(*v3discoverypb.DiscoveryRequest)
if gotVersion, wantVersion := gotReq.GetVersionInfo(), ""; gotVersion != wantVersion {
t.Errorf("Unexpected version in discovery request, got: %v, want: %v", gotVersion, wantVersion)
}
if gotNonce, wantNonce := gotReq.GetResponseNonce(), gotResp.GetNonce(); gotNonce != wantNonce {
t.Errorf("Unexpected nonce in discovery request, got: %v, want: %v", gotNonce, wantNonce)
}
if gotErr := gotReq.GetErrorDetail(); gotErr == nil || !strings.Contains(gotErr.GetMessage(), wantNackErr.Error()) {
t.Fatalf("Unexpected error in discovery request, got: %v, want: %v", gotErr.GetMessage(), wantNackErr)
}
}
// Tests the scenario where the xDS client is no longer interested in a
// resource. The following sequence of events are tested:
// 1. A resource is requested and a good response is received. The test verifies
// that an ACK is sent for this resource.
// 2. The previously requested resource is no longer requested. The test
// verifies that the connection to the management server is closed.
// 3. The same resource is requested again. The test verifies that a new
// request is sent with an empty version string, which corresponds to the
// first request on a new connection.
func (s) TestADS_ACK_NACK_ResourceIsNotRequestedAnymore(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Create an xDS management server listening on a local port. Configure the
// request and response handlers to push on channels that are inspected by
// the test goroutine to verify ACK version and nonce.
streamRequestCh := testutils.NewChannelWithSize(1)
streamResponseCh := testutils.NewChannelWithSize(1)
streamCloseCh := testutils.NewChannelWithSize(1)
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{
OnStreamRequest: func(_ int64, req *v3discoverypb.DiscoveryRequest) error {
streamRequestCh.SendContext(ctx, req)
return nil
},
OnStreamResponse: func(_ context.Context, _ int64, _ *v3discoverypb.DiscoveryRequest, resp *v3discoverypb.DiscoveryResponse) {
streamResponseCh.SendContext(ctx, resp)
},
OnStreamClosed: func(int64, *v3corepb.Node) {
streamCloseCh.SendContext(ctx, struct{}{})
},
})
// Create a listener resource on the management server.
const listenerName = "listener"
const routeConfigName = "route-config"
nodeID := uuid.New().String()
listenerResource := e2e.DefaultClientListener(listenerName, routeConfigName)
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{listenerResource},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}
// Create an xDS client pointing to the above server.
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
client := createXDSClient(t, mgmtServer.Address, nodeID, grpctransport.NewBuilder(configs))
// Register a watch for a listener resource.
lw := newListenerWatcher()
ldsCancel := client.WatchResource(xdsresource.V3ListenerURL, listenerName, lw)
defer ldsCancel()
// Verify that the initial discovery request matches expectation.
r, err := streamRequestCh.Receive(ctx)
if err != nil {
t.Fatal("Timeout when waiting for the initial discovery request")
}
gotReq := r.(*v3discoverypb.DiscoveryRequest)
wantReq := &v3discoverypb.DiscoveryRequest{
VersionInfo: "",
Node: &v3corepb.Node{
Id: nodeID,
UserAgentName: "user-agent",
UserAgentVersionType: &v3corepb.Node_UserAgentVersion{UserAgentVersion: "0.0.0.0"},
ClientFeatures: []string{"envoy.lb.does_not_support_overprovisioning", "xds.config.resource-in-sotw"},
},
ResourceNames: []string{listenerName},
TypeUrl: "type.googleapis.com/envoy.config.listener.v3.Listener",
ResponseNonce: "",
}
if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" {
t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff)
}
// Capture the version and nonce from the response.
r, err = streamResponseCh.Receive(ctx)
if err != nil {
t.Fatal("Timeout when waiting for the discovery response from client")
}
gotResp := r.(*v3discoverypb.DiscoveryResponse)
// Verify that the ACK contains the appropriate version and nonce.
r, err = streamRequestCh.Receive(ctx)
if err != nil {
t.Fatal("Timeout when waiting for ACK")
}
gotReq = r.(*v3discoverypb.DiscoveryRequest)
wantACKReq := proto.Clone(wantReq).(*v3discoverypb.DiscoveryRequest)
wantACKReq.VersionInfo = gotResp.GetVersionInfo()
wantACKReq.ResponseNonce = gotResp.GetNonce()
if diff := cmp.Diff(gotReq, wantACKReq, protocmp.Transform()); diff != "" {
t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff)
}
// Verify the update received by the watcher.
wantUpdate := listenerUpdateErrTuple{
update: listenerUpdate{RouteConfigName: routeConfigName},
}
if err := verifyListenerUpdate(ctx, lw.updateCh, wantUpdate); err != nil {
t.Fatal(err)
}
// Cancel the watch on the listener resource. This should result in the
// existing connection to be management server getting closed.
ldsCancel()
if _, err := streamCloseCh.Receive(ctx); err != nil {
t.Fatalf("Timeout when expecting existing connection to be closed: %v", err)
}
// There is a race between two events when the last watch on an xdsChannel
// is canceled:
// - an empty discovery request being sent out
// - the ADS stream being closed
// To handle this race, we drain the request channel here so that if an
// empty discovery request was received, it is pulled out of the request
// channel and thereby guaranteeing a clean slate for the next watch
// registered below.
streamRequestCh.Drain()
// Register a watch for the same listener resource.
lw = newListenerWatcher()
ldsCancel = client.WatchResource(xdsresource.V3ListenerURL, listenerName, lw)
defer ldsCancel()
// Verify that the discovery request is identical to the first one sent out
// to the management server.
r, err = streamRequestCh.Receive(ctx)
if err != nil {
t.Fatal("Timeout when waiting for discovery request")
}
gotReq = r.(*v3discoverypb.DiscoveryRequest)
if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" {
t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff)
}
// Verify the update received by the watcher.
if err := verifyListenerUpdate(ctx, lw.updateCh, wantUpdate); err != nil {
t.Fatal(err)
}
}
// TestADS_NACKError_DuplicateSuppression verifies that when a duplicate invalid
// LDS resource is sent by the server, the client suppresses the duplicate error
// notification to the watcher.
func (s) TestADS_NACKError_DuplicateSuppression(t *testing.T) {
// The first NACK is expected upon receiving the invalid resource. The
// second NACK is a duplicate, sent because the management server resends
// the invalid resource in a loop as long as the resource update has not
// changed to a valid one. The third NACK is waited for to verify that the
// duplicated error is still suppressed as expected.
const wantNACKCount = 3
nacksReceivedCh := make(chan struct{})
nackCount := 0
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{
OnStreamRequest: func(_ int64, req *v3discoverypb.DiscoveryRequest) error {
if req.GetTypeUrl() != xdsresource.V3ListenerURL {
return nil
}
if req.GetErrorDetail() != nil {
nackCount++
if nackCount == wantNACKCount {
close(nacksReceivedCh)
}
}
return nil
},
})
const listenerName = "listener"
nodeID := uuid.New().String()
// Create an xDS client pointing to the above server.
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
client := createXDSClient(t, mgmtServer.Address, nodeID, grpctransport.NewBuilder(configs))
watcher := newListenerWatcher()
ldsCancel := client.WatchResource(xdsresource.V3ListenerURL, listenerName, watcher)
defer ldsCancel()
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{badListenerResource(t, listenerName)},
SkipValidation: true,
}
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server with resources: %v, err: %v", resources, err)
}
// Verify that the watcher receives the initial error.
v, err := watcher.resourceErrCh.Receive(ctx)
if err != nil {
t.Fatalf("Timeout when waiting for an endpoints resource error from the management server: %v", err)
}
const wantNackErr = "no RouteSpecifier"
if gotErr := v.(listenerUpdateErrTuple).resourceErr; !strings.Contains(gotErr.Error(), wantNackErr) {
t.Fatalf("Update received with error: %v, want %q", gotErr, wantNackErr)
}
// Wait for the management server to receive at least wantNACKCount NACKs,
// indicating that the NACK resend loop has run multiple times.
select {
case <-nacksReceivedCh:
case <-ctx.Done():
t.Fatalf("Timeout waiting for %d NACKs to be received by the server: %v", wantNACKCount, ctx.Err())
}
// Verify that no duplicate error update was written to the error channel,
// proving that subsequent duplicate error updates were suppressed.
sCtx, sCancel := context.WithTimeout(ctx, defaultTestShortTimeout)
defer sCancel()
if v, err := watcher.resourceErrCh.Receive(sCtx); err == nil {
t.Fatalf("Unexpected duplicate error received by watcher: %v", v)
}
}
// TestADS_NACKError_DuplicateSuppression_MultipleResources verifies that
// when multiple invalid resources cause errors, and a subsequent update carries
// a duplicate error for only a subset of resources, the client still suppresses
// the duplicate error notification to the watcher of the affected resource(s).
func (s) TestADS_NACKError_DuplicateSuppression_MultipleResources(t *testing.T) {
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{})
const listenerName1 = "listener-1"
const listenerName2 = "listener-2"
nodeID := uuid.New().String()
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
client := createXDSClient(t, mgmtServer.Address, nodeID, grpctransport.NewBuilder(configs))
watcher1 := newListenerWatcher()
ldsCancel1 := client.WatchResource(xdsresource.V3ListenerURL, listenerName1, watcher1)
defer ldsCancel1()
watcher2 := newListenerWatcher()
ldsCancel2 := client.WatchResource(xdsresource.V3ListenerURL, listenerName2, watcher2)
defer ldsCancel2()
// Update 1: Management server sends two invalid listener resources.
resources1 := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{
badListenerResource(t, listenerName1),
badListenerResource(t, listenerName2),
},
SkipValidation: true,
}
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
if err := mgmtServer.Update(ctx, resources1); err != nil {
t.Fatalf("Failed to update management server with resources: %v, err: %v", resources1, err)
}
// Verify that watcher1 receives the initial error for listener 1.
v1, err := watcher1.resourceErrCh.Receive(ctx)
if err != nil {
t.Fatalf("Timeout waiting for listener 1 error from management server: %v", err)
}
const wantNackErr = "no RouteSpecifier"
if gotErr := v1.(listenerUpdateErrTuple).resourceErr; !strings.Contains(gotErr.Error(), wantNackErr) {
t.Fatalf("Update 1 listener 1 received with error: %v, want %q", gotErr, wantNackErr)
}
// Verify that watcher2 receives the initial error for listener 2.
v2, err := watcher2.resourceErrCh.Receive(ctx)
if err != nil {
t.Fatalf("Timeout waiting for listener 2 error from management server: %v", err)
}
if gotErr := v2.(listenerUpdateErrTuple).resourceErr; !strings.Contains(gotErr.Error(), wantNackErr) {
t.Fatalf("Update 1 listener 2 received with error: %v, want %q", gotErr, wantNackErr)
}
// Update 2: Management server sends listener 1 still invalid, but listener
// 2 now valid.
resources2 := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{
badListenerResource(t, listenerName1),
e2e.DefaultClientListener(listenerName2, "route-config-2"),
},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources2); err != nil {
t.Fatalf("Failed to update management server with resources: %v, err: %v", resources2, err)
}
// Wait for watcher2 to receive the valid resource update for listener 2.
if _, err := watcher2.updateCh.Receive(ctx); err != nil {
t.Fatalf("Timeout waiting for listener 2 resource update: %v", err)
}
// Verify that no duplicate error update was written to watcher 1.
sCtx, sCancel := context.WithTimeout(ctx, defaultTestShortTimeout)
defer sCancel()
if v, err := watcher1.resourceErrCh.Receive(sCtx); err == nil {
t.Fatalf("Unexpected duplicate error received by watcher 1: %v", v)
}
}

View File

@@ -0,0 +1,444 @@
/*
*
* Copyright 2024 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package xdsclient_test
import (
"context"
"errors"
"fmt"
"net"
"testing"
"time"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/internal/xds/clients/grpctransport"
"google.golang.org/grpc/internal/xds/clients/internal/testutils"
"google.golang.org/grpc/internal/xds/clients/internal/testutils/e2e"
"google.golang.org/grpc/internal/xds/clients/xdsclient"
xdsclientinternal "google.golang.org/grpc/internal/xds/clients/xdsclient/internal"
"google.golang.org/grpc/internal/xds/clients/xdsclient/internal/xdsresource"
"google.golang.org/grpc/internal/xds/xdsclient/xdsresource/version"
"google.golang.org/protobuf/testing/protocmp"
v3corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
v3discoverypb "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/uuid"
)
func overrideStreamBackOff(t *testing.T, streamBackOff func(int) time.Duration) {
originalStreamBackoff := xdsclientinternal.StreamBackoff
xdsclientinternal.StreamBackoff = streamBackOff
t.Cleanup(func() { xdsclientinternal.StreamBackoff = originalStreamBackoff })
}
// Creates an xDS client with the given management server address, nodeID and backoff function.
func createXDSClientWithBackoff(t *testing.T, mgmtServerAddress string, nodeID string, streamBackoff func(int) time.Duration) *xdsclient.XDSClient {
t.Helper()
overrideStreamBackOff(t, streamBackoff)
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
return createXDSClient(t, mgmtServerAddress, nodeID, grpctransport.NewBuilder(configs))
}
// Tests the case where the management server returns an error in the ADS
// streaming RPC. Verifies that the ADS stream is restarted after a backoff
// period, and that the previously requested resources are re-requested on the
// new stream.
func (s) TestADS_BackoffAfterStreamFailure(t *testing.T) {
// Channels used for verifying different events in the test.
streamCloseCh := make(chan struct{}, 1) // ADS stream is closed.
ldsResourcesCh := make(chan []string, 1) // Listener resource names in the discovery request.
backoffCh := make(chan struct{}, 1) // Backoff after stream failure.
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Create an xDS management server that returns RPC errors.
streamErr := errors.New("ADS stream error")
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{
OnStreamRequest: func(_ int64, req *v3discoverypb.DiscoveryRequest) error {
// Push the requested resource names on to a channel.
if req.GetTypeUrl() == version.V3ListenerURL {
t.Logf("Received LDS request for resources: %v", req.GetResourceNames())
select {
case ldsResourcesCh <- req.GetResourceNames():
case <-ctx.Done():
}
}
// Return an error everytime a request is sent on the stream. This
// should cause the transport to backoff before attempting to
// recreate the stream.
return streamErr
},
// Push on a channel whenever the stream is closed.
OnStreamClosed: func(int64, *v3corepb.Node) {
select {
case streamCloseCh <- struct{}{}:
case <-ctx.Done():
}
},
})
// Override the backoff implementation to push on a channel that is read by
// the test goroutine.
backoffCtx, backoffCancel := context.WithCancel(ctx)
streamBackoff := func(int) time.Duration {
select {
case backoffCh <- struct{}{}:
case <-backoffCtx.Done():
}
return 0
}
defer backoffCancel()
// Create an xDS client with bootstrap pointing to the above server.
nodeID := uuid.New().String()
client := createXDSClientWithBackoff(t, mgmtServer.Address, nodeID, streamBackoff)
// Register a watch for a listener resource.
const listenerName = "listener"
lw := newListenerWatcher()
ldsCancel := client.WatchResource(xdsresource.V3ListenerURL, listenerName, lw)
defer ldsCancel()
// Verify that an ADS stream is created and an LDS request with the above
// resource name is sent.
if err := waitForResourceNames(ctx, t, ldsResourcesCh, []string{listenerName}); err != nil {
t.Fatal(err)
}
// Verify that the received stream error is reported to the watcher.
if err := verifyListenerResourceError(ctx, lw.resourceErrCh, streamErr.Error(), nodeID); err != nil {
t.Fatal(err)
}
// Verify that the stream is closed.
select {
case <-streamCloseCh:
case <-ctx.Done():
t.Fatalf("Timeout waiting for stream to be closed after an error")
}
// Verify that the ADS stream backs off before recreating the stream.
select {
case <-backoffCh:
case <-ctx.Done():
t.Fatalf("Timeout waiting for ADS stream to backoff after stream failure")
}
// Verify that the same resource name is re-requested on the new stream.
if err := waitForResourceNames(ctx, t, ldsResourcesCh, []string{listenerName}); err != nil {
t.Fatal(err)
}
// To prevent indefinite blocking during xDS client close, which is caused
// by a blocking backoff channel write, cancel the backoff context early
// given that the test is complete.
backoffCancel()
}
// Tests the case where a stream breaks because the server goes down. Verifies
// that when the server comes back up, the same resources are re-requested, this
// time with the previously acked version and an empty nonce.
func (s) TestADS_RetriesAfterBrokenStream(t *testing.T) {
// Channels used for verifying different events in the test.
streamRequestCh := make(chan *v3discoverypb.DiscoveryRequest, 1) // Discovery request is received.
streamResponseCh := make(chan *v3discoverypb.DiscoveryResponse, 1) // Discovery response is received.
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Create an xDS management server listening on a local port.
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("net.Listen() failed: %v", err)
}
lis := testutils.NewRestartableListener(l)
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{
Listener: lis,
// Push the received request on to a channel for the test goroutine to
// verify that it matches expectations.
OnStreamRequest: func(_ int64, req *v3discoverypb.DiscoveryRequest) error {
select {
case streamRequestCh <- req:
case <-ctx.Done():
}
return nil
},
// Push the response that the management server is about to send on to a
// channel. The test goroutine to uses this to extract the version and
// nonce, expected on subsequent requests.
OnStreamResponse: func(_ context.Context, _ int64, _ *v3discoverypb.DiscoveryRequest, resp *v3discoverypb.DiscoveryResponse) {
select {
case streamResponseCh <- resp:
case <-ctx.Done():
}
},
})
// Create a listener resource on the management server.
const listenerName = "listener"
const routeConfigName = "route-config"
nodeID := uuid.New().String()
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{e2e.DefaultClientListener(listenerName, routeConfigName)},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}
// Override the backoff implementation to always return 0, to reduce test
// run time. Instead control when the backoff returns by blocking on a
// channel, that the test closes.
backoffCh := make(chan struct{})
streamBackoff := func(int) time.Duration {
select {
case backoffCh <- struct{}{}:
case <-ctx.Done():
}
return 0
}
// Create an xDS client pointing to the above server.
client := createXDSClientWithBackoff(t, mgmtServer.Address, nodeID, streamBackoff)
// Register a watch for a listener resource.
lw := newListenerWatcher()
ldsCancel := client.WatchResource(xdsresource.V3ListenerURL, listenerName, lw)
defer ldsCancel()
// Verify that the initial discovery request matches expectation.
var gotReq *v3discoverypb.DiscoveryRequest
select {
case gotReq = <-streamRequestCh:
case <-ctx.Done():
t.Fatalf("Timeout waiting for discovery request on the stream")
}
wantReq := &v3discoverypb.DiscoveryRequest{
VersionInfo: "",
Node: &v3corepb.Node{
Id: nodeID,
UserAgentName: "user-agent",
UserAgentVersionType: &v3corepb.Node_UserAgentVersion{UserAgentVersion: "0.0.0.0"},
ClientFeatures: []string{"envoy.lb.does_not_support_overprovisioning", "xds.config.resource-in-sotw"},
},
ResourceNames: []string{listenerName},
TypeUrl: "type.googleapis.com/envoy.config.listener.v3.Listener",
ResponseNonce: "",
}
if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" {
t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff)
}
// Capture the version and nonce from the response.
var gotResp *v3discoverypb.DiscoveryResponse
select {
case gotResp = <-streamResponseCh:
case <-ctx.Done():
t.Fatalf("Timeout waiting for discovery response on the stream")
}
version := gotResp.GetVersionInfo()
nonce := gotResp.GetNonce()
// Verify that the ACK contains the appropriate version and nonce.
wantReq.VersionInfo = version
wantReq.ResponseNonce = nonce
select {
case gotReq = <-streamRequestCh:
case <-ctx.Done():
t.Fatalf("Timeout waiting for the discovery request ACK on the stream")
}
if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" {
t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff)
}
// Verify the update received by the watcher.
wantUpdate := listenerUpdateErrTuple{
update: listenerUpdate{
RouteConfigName: routeConfigName},
}
if err := verifyListenerUpdate(ctx, lw.updateCh, wantUpdate); err != nil {
t.Fatal(err)
}
// Bring down the management server to simulate a broken stream.
lis.Stop()
// Verify that the error callback on the watcher is not invoked.
verifyNoListenerUpdate(ctx, lw.updateCh)
// Wait for backoff to kick in, and unblock the first backoff attempt.
select {
case <-backoffCh:
case <-ctx.Done():
t.Fatal("Timeout waiting for stream backoff")
}
// Bring up the management server. The test does not have prcecise control
// over when new streams to the management server will start succeeding. The
// ADS stream implementation will backoff as many times as required before
// it can successfully create a new stream. Therefore, we need to receive on
// the backoffCh as many times as required, and unblock the backoff
// implementation.
lis.Restart()
go func() {
for {
select {
case <-backoffCh:
case <-ctx.Done():
return
}
}
}()
// Verify that the transport creates a new stream and sends out a new
// request which contains the previously acked version, but an empty nonce.
wantReq.ResponseNonce = ""
select {
case gotReq = <-streamRequestCh:
case <-ctx.Done():
t.Fatalf("Timeout waiting for the discovery request ACK on the stream")
}
if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" {
t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff)
}
}
// Tests the case where a resource is requested before the a valid ADS stream
// exists. Verifies that the a discovery request is sent out for the previously
// requested resource once a valid stream is created.
func (s) TestADS_ResourceRequestedBeforeStreamCreation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Channels used for verifying different events in the test.
streamRequestCh := make(chan *v3discoverypb.DiscoveryRequest, 1) // Discovery request is received.
// Create an xDS management server listening on a local port.
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("net.Listen() failed: %v", err)
}
lis := testutils.NewRestartableListener(l)
streamErr := errors.New("ADS stream error")
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{
Listener: lis,
// Return an error everytime a request is sent on the stream. This
// should cause the transport to backoff before attempting to recreate
// the stream.
OnStreamRequest: func(_ int64, req *v3discoverypb.DiscoveryRequest) error {
select {
case streamRequestCh <- req:
default:
}
return streamErr
},
})
// Bring down the management server before creating the transport. This
// allows us to test the case where SendRequest() is called when there is no
// stream to the management server.
lis.Stop()
// Override the backoff implementation to always return 0, to reduce test
// run time. Instead control when the backoff returns by blocking on a
// channel, that the test closes.
backoffCh := make(chan struct{}, 1)
unblockBackoffCh := make(chan struct{})
streamBackoff := func(int) time.Duration {
select {
case backoffCh <- struct{}{}:
default:
}
<-unblockBackoffCh
return 0
}
// Create an xDS client with bootstrap pointing to the above server.
nodeID := uuid.New().String()
client := createXDSClientWithBackoff(t, mgmtServer.Address, nodeID, streamBackoff)
// Register a watch for a listener resource.
const listenerName = "listener"
lw := newListenerWatcher()
ldsCancel := client.WatchResource(xdsresource.V3ListenerURL, listenerName, lw)
defer ldsCancel()
// The above watch results in an attempt to create a new stream, which will
// fail, and will result in backoff. Wait for backoff to kick in.
select {
case <-backoffCh:
case <-ctx.Done():
t.Fatal("Timeout waiting for stream backoff")
}
// Bring up the connection to the management server, and unblock the backoff
// implementation.
lis.Restart()
close(unblockBackoffCh)
// Verify that the initial discovery request matches expectation.
var gotReq *v3discoverypb.DiscoveryRequest
select {
case gotReq = <-streamRequestCh:
case <-ctx.Done():
t.Fatalf("Timeout waiting for discovery request on the stream")
}
wantReq := &v3discoverypb.DiscoveryRequest{
VersionInfo: "",
Node: &v3corepb.Node{
Id: nodeID,
UserAgentName: "user-agent",
UserAgentVersionType: &v3corepb.Node_UserAgentVersion{UserAgentVersion: "0.0.0.0"},
ClientFeatures: []string{"envoy.lb.does_not_support_overprovisioning", "xds.config.resource-in-sotw"},
},
ResourceNames: []string{listenerName},
TypeUrl: "type.googleapis.com/envoy.config.listener.v3.Listener",
ResponseNonce: "",
}
if diff := cmp.Diff(gotReq, wantReq, protocmp.Transform()); diff != "" {
t.Fatalf("Unexpected diff in received discovery request, diff (-got, +want):\n%s", diff)
}
}
// waitForResourceNames waits for the wantNames to be received on namesCh.
// Returns a non-nil error if the context expires before that.
func waitForResourceNames(ctx context.Context, t *testing.T, namesCh chan []string, wantNames []string) error {
t.Helper()
var lastRequestedNames []string
for ; ; <-time.After(defaultTestShortTimeout) {
select {
case <-ctx.Done():
return fmt.Errorf("timeout waiting for resources %v to be requested from the management server. Last requested resources: %v", wantNames, lastRequestedNames)
case gotNames := <-namesCh:
if cmp.Equal(gotNames, wantNames, cmpopts.EquateEmpty(), cmpopts.SortSlices(func(s1, s2 string) bool { return s1 < s2 })) {
return nil
}
lastRequestedNames = gotNames
}
}
}

View File

@@ -0,0 +1,625 @@
/*
*
* Copyright 2024 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package xdsclient_test
import (
"context"
"fmt"
"slices"
"sort"
"testing"
"time"
v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
v3discoverypb "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3"
"github.com/google/uuid"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/internal/testutils/xds/e2e"
"google.golang.org/grpc/internal/xds/clients"
"google.golang.org/grpc/internal/xds/clients/xdsclient"
"google.golang.org/grpc/internal/xds/clients/xdsclient/internal/xdsresource"
"google.golang.org/grpc/internal/xds/xdsclient/xdsresource/version"
)
// blockingListenerWatcher implements xdsresource.ListenerWatcher. It writes to
// a channel when it receives a callback from the watch. It also makes the
// DoneNotifier passed to the callback available to the test, thereby enabling
// the test to block this watcher for as long as required.
type blockingListenerWatcher struct {
doneNotifierCh chan func() // DoneNotifier passed to the callback.
updateCh chan struct{} // Written to when an update is received.
ambientErrCh chan struct{} // Written to when an ambient error is received.
resourceErrCh chan struct{} // Written to when a resource error is received.
}
func newBLockingListenerWatcher() *blockingListenerWatcher {
return &blockingListenerWatcher{
doneNotifierCh: make(chan func(), 1),
updateCh: make(chan struct{}, 1),
ambientErrCh: make(chan struct{}, 1),
resourceErrCh: make(chan struct{}, 1),
}
}
func (lw *blockingListenerWatcher) ResourceChanged(_ xdsclient.ResourceData, done func()) {
// Notify receipt of the update.
select {
case lw.updateCh <- struct{}{}:
default:
}
select {
case lw.doneNotifierCh <- done:
default:
}
}
func (lw *blockingListenerWatcher) ResourceError(_ error, done func()) {
// Notify receipt of an error.
select {
case lw.resourceErrCh <- struct{}{}:
default:
}
select {
case lw.doneNotifierCh <- done:
default:
}
}
func (lw *blockingListenerWatcher) AmbientError(_ error, done func()) {
// Notify receipt of an error.
select {
case lw.ambientErrCh <- struct{}{}:
default:
}
select {
case lw.doneNotifierCh <- done:
default:
}
}
type transportBuilder struct {
adsStreamCh chan *stream
}
func (b *transportBuilder) Build(si clients.ServerIdentifier) (clients.Transport, error) {
cc, err := grpc.NewClient(si.ServerURI, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithDefaultCallOptions(grpc.ForceCodec(&byteCodec{})))
if err != nil {
return nil, err
}
return &transport{cc: cc, adsStreamCh: b.adsStreamCh}, nil
}
type transport struct {
cc *grpc.ClientConn
adsStreamCh chan *stream
}
func (t *transport) NewStream(ctx context.Context, method string) (clients.Stream, error) {
s, err := t.cc.NewStream(ctx, &grpc.StreamDesc{ClientStreams: true, ServerStreams: true}, method)
if err != nil {
return nil, err
}
stream := &stream{
stream: s,
recvCh: make(chan struct{}, 1),
}
t.adsStreamCh <- stream
return stream, nil
}
func (t *transport) Close() {
t.cc.Close()
}
type stream struct {
stream grpc.ClientStream
recvCh chan struct{}
}
func (s *stream) Send(msg []byte) error {
return s.stream.SendMsg(msg)
}
func (s *stream) Recv() ([]byte, error) {
select {
case s.recvCh <- struct{}{}:
case <-s.stream.Context().Done():
// Unblock the recv() once the stream is done.
}
var typedRes []byte
if err := s.stream.RecvMsg(&typedRes); err != nil {
return nil, err
}
return typedRes, nil
}
type byteCodec struct{}
func (c *byteCodec) Marshal(v any) ([]byte, error) {
if b, ok := v.([]byte); ok {
return b, nil
}
return nil, fmt.Errorf("transport: message is %T, but must be a []byte", v)
}
func (c *byteCodec) Unmarshal(data []byte, v any) error {
if b, ok := v.(*[]byte); ok {
*b = data
return nil
}
return fmt.Errorf("transport: target is %T, but must be *[]byte", v)
}
func (c *byteCodec) Name() string {
return "transport.byteCodec"
}
// Tests ADS stream level flow control with a single resource. The test does the
// following:
// - Starts a management server and configures a listener resource on it.
// - Creates an xDS client to the above management server, starts a couple of
// listener watchers for the above resource, and verifies that the update
// reaches these watchers.
// - These watchers don't invoke the onDone callback until explicitly
// triggered by the test. This allows the test to verify that the next
// Recv() call on the ADS stream does not happen until both watchers have
// completely processed the update, i.e invoke the onDone callback.
// - Resource is updated on the management server, and the test verifies that
// the update reaches the watchers.
func (s) TestADSFlowControl_ResourceUpdates_SingleResource(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Start an xDS management server.
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{})
nodeID := uuid.New().String()
// Create an xDS client pointing to the above server with a test transport
// that allow monitoring the underlying stream through adsStreamCh.
adsStreamCh := make(chan *stream, 1)
client := createXDSClient(t, mgmtServer.Address, nodeID, &transportBuilder{adsStreamCh: adsStreamCh})
// Configure two watchers for the same listener resource.
const listenerResourceName = "test-listener-resource"
const routeConfigurationName = "test-route-configuration-resource"
watcher1 := newBLockingListenerWatcher()
cancel1 := client.WatchResource(xdsresource.V3ListenerURL, listenerResourceName, watcher1)
defer cancel1()
watcher2 := newBLockingListenerWatcher()
cancel2 := client.WatchResource(xdsresource.V3ListenerURL, listenerResourceName, watcher2)
defer cancel2()
// Wait for the ADS stream to be created.
var adsStream *stream
select {
case adsStream = <-adsStreamCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for ADS stream to be created")
}
// Configure the listener resource on the management server.
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{e2e.DefaultClientListener(listenerResourceName, routeConfigurationName)},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server with resources: %v, err: %v", resources, err)
}
// Ensure that there is a read on the stream.
select {
case <-adsStream.recvCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for ADS stream to be read from")
}
// Wait for the update to reach the watchers.
select {
case <-watcher1.updateCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for update to reach watcher 1")
}
select {
case <-watcher2.updateCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for update to reach watcher 2")
}
// Update the listener resource on the management server to point to a new
// route configuration resource.
resources = e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{e2e.DefaultClientListener(listenerResourceName, "new-route")},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server with resources: %v, err: %v", resources, err)
}
// Unblock one watcher.
onDone := <-watcher1.doneNotifierCh
onDone()
// Wait for a short duration and ensure that there is no read on the stream.
select {
case <-adsStream.recvCh:
t.Fatal("Recv() called on the ADS stream before all watchers have processed the previous update")
case <-time.After(defaultTestShortTimeout):
}
// Unblock the second watcher.
onDone = <-watcher2.doneNotifierCh
onDone()
// Ensure that there is a read on the stream, now that the previous update
// has been consumed by all watchers.
select {
case <-adsStream.recvCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for Recv() to be called on the ADS stream after all watchers have processed the previous update")
}
// Wait for the new update to reach the watchers.
select {
case <-watcher1.updateCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for update to reach watcher 1")
}
select {
case <-watcher2.updateCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for update to reach watcher 2")
}
// At this point, the xDS client is shut down (and the associated transport
// is closed) without the watchers invoking their respective onDone
// callbacks. This verifies that the closing a transport that has pending
// watchers does not block.
}
// Tests ADS stream level flow control with a multiple resources. The test does
// the following:
// - Starts a management server and configures two listener resources on it.
// - Creates an xDS client to the above management server, starts a couple of
// listener watchers for the two resources, and verifies that the update
// reaches these watchers.
// - These watchers don't invoke the onDone callback until explicitly
// triggered by the test. This allows the test to verify that the next
// Recv() call on the ADS stream does not happen until both watchers have
// completely processed the update, i.e invoke the onDone callback.
func (s) TestADSFlowControl_ResourceUpdates_MultipleResources(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Start an xDS management server.
const listenerResourceName1 = "test-listener-resource-1"
const listenerResourceName2 = "test-listener-resource-2"
wantResourceNames := []string{listenerResourceName1, listenerResourceName2}
requestCh := make(chan struct{}, 1)
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{
OnStreamRequest: func(_ int64, req *v3discoverypb.DiscoveryRequest) error {
if req.GetTypeUrl() != version.V3ListenerURL {
return nil
}
gotResourceNames := req.GetResourceNames()
sort.Slice(gotResourceNames, func(i, j int) bool { return req.ResourceNames[i] < req.ResourceNames[j] })
if slices.Equal(gotResourceNames, wantResourceNames) {
// The two resource names will be part of the initial request
// and also the ACK. Hence, we need to make this write
// non-blocking.
select {
case requestCh <- struct{}{}:
default:
}
}
return nil
},
})
nodeID := uuid.New().String()
// Create an xDS client pointing to the above server with a test transport
// that allow monitoring the underlying stream through adsStreamCh.
adsStreamCh := make(chan *stream, 1)
client := createXDSClient(t, mgmtServer.Address, nodeID, &transportBuilder{adsStreamCh: adsStreamCh})
// Configure two watchers for two different listener resources.
const routeConfigurationName1 = "test-route-configuration-resource-1"
watcher1 := newBLockingListenerWatcher()
cancel1 := client.WatchResource(xdsresource.V3ListenerURL, listenerResourceName1, watcher1)
defer cancel1()
const routeConfigurationName2 = "test-route-configuration-resource-2"
watcher2 := newBLockingListenerWatcher()
cancel2 := client.WatchResource(xdsresource.V3ListenerURL, listenerResourceName2, watcher2)
defer cancel2()
// Wait for the wrapped ADS stream to be created.
var adsStream *stream
select {
case adsStream = <-adsStreamCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for ADS stream to be created")
}
// Ensure that there is a read on the stream.
select {
case <-adsStream.recvCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for ADS stream to be read from")
}
// Wait for both resource names to be requested.
select {
case <-requestCh:
case <-ctx.Done():
t.Fatal("Timed out waiting for both resource names to be requested")
}
// Configure the listener resources on the management server.
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{
e2e.DefaultClientListener(listenerResourceName1, routeConfigurationName1),
e2e.DefaultClientListener(listenerResourceName2, routeConfigurationName2),
},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server with resources: %v, err: %v", resources, err)
}
// At this point, we expect the management server to send both resources in
// the same response. So, both watchers would be notified at the same time,
// and no more Recv() calls should happen until both of them have invoked
// their respective onDone() callbacks.
// The order of callback invocations among the two watchers is not
// guaranteed. So, we select on both of them and unblock the first watcher
// whose callback is invoked.
var otherWatcherUpdateCh chan struct{}
var otherWatcherDoneCh chan func()
select {
case <-watcher1.updateCh:
onDone := <-watcher1.doneNotifierCh
onDone()
otherWatcherUpdateCh = watcher2.updateCh
otherWatcherDoneCh = watcher2.doneNotifierCh
case <-watcher2.updateCh:
onDone := <-watcher2.doneNotifierCh
onDone()
otherWatcherUpdateCh = watcher1.updateCh
otherWatcherDoneCh = watcher1.doneNotifierCh
case <-ctx.Done():
t.Fatal("Timed out waiting for update to reach first watchers")
}
// Wait for a short duration and ensure that there is no read on the stream.
select {
case <-adsStream.recvCh:
t.Fatal("Recv() called on the ADS stream before all watchers have processed the previous update")
case <-time.After(defaultTestShortTimeout):
}
// Wait for the update on the second watcher and unblock it.
select {
case <-otherWatcherUpdateCh:
onDone := <-otherWatcherDoneCh
onDone()
case <-ctx.Done():
t.Fatal("Timed out waiting for update to reach second watcher")
}
// Ensure that there is a read on the stream, now that the previous update
// has been consumed by all watchers.
select {
case <-adsStream.recvCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for Recv() to be called on the ADS stream after all watchers have processed the previous update")
}
}
// Test ADS stream flow control with a single resource that is expected to be
// NACKed by the xDS client and the watcher's ResourceError() callback is
// expected to be invoked because resource is not cached. Verifies that no
// further reads are attempted until the error is completely processed by the
// watcher.
func (s) TestADSFlowControl_ResourceErrors(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Start an xDS management server.
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{})
nodeID := uuid.New().String()
// Create an xDS client pointing to the above server with a test transport
// that allow monitoring the underlying stream through adsStreamCh.
adsStreamCh := make(chan *stream, 1)
client := createXDSClient(t, mgmtServer.Address, nodeID, &transportBuilder{adsStreamCh: adsStreamCh})
// Configure a watcher for a listener resource.
const listenerResourceName = "test-listener-resource"
watcher := newBLockingListenerWatcher()
cancel = client.WatchResource(xdsresource.V3ListenerURL, listenerResourceName, watcher)
defer cancel()
// Wait for the stream to be created.
var adsStream *stream
select {
case adsStream = <-adsStreamCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for ADS stream to be created")
}
// Configure the management server to return a single listener resource
// which is expected to be NACKed by the client.
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{badListenerResource(t, listenerResourceName)},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server with resources: %v, err: %v", resources, err)
}
// Ensure that there is a read on the stream.
select {
case <-adsStream.recvCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for ADS stream to be read from")
}
// Wait for the resource error to reach the watcher.
select {
case <-watcher.resourceErrCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for error to reach watcher")
}
// Wait for a short duration and ensure that there is no read on the stream.
select {
case <-adsStream.recvCh:
t.Fatal("Recv() called on the ADS stream before all watchers have processed the previous update")
case <-time.After(defaultTestShortTimeout):
}
// Unblock one watcher.
onDone := <-watcher.doneNotifierCh
onDone()
// Ensure that there is a read on the stream, now that the previous error
// has been consumed by the watcher.
select {
case <-adsStream.recvCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for Recv() to be called on the ADS stream after all watchers have processed the previous update")
}
}
// Test ADS stream flow control with a single resource that is deleted from the
// management server and therefore the watcher's ResourceError()
// callback is expected to be invoked. Verifies that no further reads are
// attempted until the callback is completely handled by the watcher.
func (s) TestADSFlowControl_ResourceDoesNotExist(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Start an xDS management server.
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{})
nodeID := uuid.New().String()
// Create an xDS client pointing to the above server with a test transport
// that allow monitoring the underlying stream through adsStreamCh.
adsStreamCh := make(chan *stream, 1)
client := createXDSClient(t, mgmtServer.Address, nodeID, &transportBuilder{adsStreamCh: adsStreamCh})
// Configure a watcher for a listener resource.
const listenerResourceName = "test-listener-resource"
const routeConfigurationName = "test-route-configuration-resource"
watcher := newBLockingListenerWatcher()
cancel = client.WatchResource(xdsresource.V3ListenerURL, listenerResourceName, watcher)
defer cancel()
// Wait for the ADS stream to be created.
var adsStream *stream
select {
case adsStream = <-adsStreamCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for ADS stream to be created")
}
// Configure the listener resource on the management server.
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{e2e.DefaultClientListener(listenerResourceName, routeConfigurationName)},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server with resources: %v, err: %v", resources, err)
}
// Ensure that there is a read on the stream.
select {
case <-adsStream.recvCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for Recv() to be called on the ADS stream")
}
// Wait for the update to reach the watcher and unblock it.
select {
case <-watcher.updateCh:
onDone := <-watcher.doneNotifierCh
onDone()
case <-ctx.Done():
t.Fatalf("Timed out waiting for update to reach watcher 1")
}
// Ensure that there is a read on the stream.
select {
case <-adsStream.recvCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for Recv() to be called on the ADS stream")
}
// Remove the listener resource on the management server.
resources = e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server with resources: %v, err: %v", resources, err)
}
// Wait for the resource not found callback to be invoked.
select {
case <-watcher.resourceErrCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for resource not found callback to be invoked on the watcher")
}
// Wait for a short duration and ensure that there is no read on the stream.
select {
case <-adsStream.recvCh:
t.Fatal("Recv() called on the ADS stream before all watchers have processed the previous update")
case <-time.After(defaultTestShortTimeout):
}
// Unblock the watcher.
onDone := <-watcher.doneNotifierCh
onDone()
// Ensure that there is a read on the stream.
select {
case <-adsStream.recvCh:
case <-ctx.Done():
t.Fatalf("Timed out waiting for Recv() to be called on the ADS stream")
}
}

View File

@@ -0,0 +1,202 @@
/*
*
* Copyright 2024 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package xdsclient_test
import (
"context"
"net"
"testing"
"github.com/google/uuid"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/internal/testutils/xds/e2e"
"google.golang.org/grpc/internal/xds/clients/grpctransport"
"google.golang.org/grpc/internal/xds/clients/internal/testutils"
"google.golang.org/grpc/internal/xds/clients/xdsclient/internal/xdsresource"
"google.golang.org/grpc/internal/xds/xdsclient/xdsresource/version"
v3corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
v3discoverypb "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3"
)
// Tests that an ADS stream is restarted after a connection failure. Also
// verifies that if there were any watches registered before the connection
// failed, those resources are re-requested after the stream is restarted.
func (s) TestADS_ResourcesAreRequestedAfterStreamRestart(t *testing.T) {
// Create a restartable listener that can simulate a broken ADS stream.
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("net.Listen() failed: %v", err)
}
lis := testutils.NewRestartableListener(l)
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Start an xDS management server that uses a couple of channels to inform
// the test about the specific LDS and CDS resource names being requested.
ldsResourcesCh := make(chan []string, 2)
streamOpened := make(chan struct{}, 1)
streamClosed := make(chan struct{}, 1)
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{
Listener: lis,
OnStreamRequest: func(_ int64, req *v3discoverypb.DiscoveryRequest) error {
t.Logf("Received request for resources: %v of type %s", req.GetResourceNames(), req.GetTypeUrl())
// Drain the resource name channels before writing to them to ensure
// that the most recently requested names are made available to the
// test.
if req.GetTypeUrl() == version.V3ListenerURL {
select {
case <-ldsResourcesCh:
default:
}
ldsResourcesCh <- req.GetResourceNames()
}
return nil
},
OnStreamClosed: func(int64, *v3corepb.Node) {
select {
case streamClosed <- struct{}{}:
default:
}
},
OnStreamOpen: func(context.Context, int64, string) error {
select {
case streamOpened <- struct{}{}:
default:
}
return nil
},
})
// Create a listener resource on the management server.
const listenerName = "listener"
const routeConfigName = "route-config"
nodeID := uuid.New().String()
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{e2e.DefaultClientListener(listenerName, routeConfigName)},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}
// Create an xDS client pointing to the above server.
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
client := createXDSClient(t, mgmtServer.Address, nodeID, grpctransport.NewBuilder(configs))
// Register a watch for a listener resource.
lw := newListenerWatcher()
ldsCancel := client.WatchResource(xdsresource.V3ListenerURL, listenerName, lw)
defer ldsCancel()
// Verify that an ADS stream is opened and an LDS request with the above
// resource name is sent.
select {
case <-streamOpened:
case <-ctx.Done():
t.Fatal("Timeout when waiting for ADS stream to open")
}
if err := waitForResourceNames(ctx, t, ldsResourcesCh, []string{listenerName}); err != nil {
t.Fatal(err)
}
// Verify the update received by the watcher.
wantListenerUpdate := listenerUpdateErrTuple{
update: listenerUpdate{
RouteConfigName: routeConfigName,
},
}
if err := verifyListenerUpdate(ctx, lw.updateCh, wantListenerUpdate); err != nil {
t.Fatal(err)
}
// Create another listener resource on the management server, in addition
// to the existing listener resource.
const listenerName2 = "listener2"
const routeConfigName2 = "route-config2"
resources = e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{e2e.DefaultClientListener(listenerName, routeConfigName), e2e.DefaultClientListener(listenerName2, routeConfigName2)},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}
// Register a watch for another listener resource, and verify that a LDS request
// with the both listener resource names are sent.
lw2 := newListenerWatcher()
ldsCancel2 := client.WatchResource(xdsresource.V3ListenerURL, listenerName2, lw2)
if err := waitForResourceNames(ctx, t, ldsResourcesCh, []string{listenerName, listenerName2}); err != nil {
t.Fatal(err)
}
// Verify the update received by the watcher.
wantListenerUpdate = listenerUpdateErrTuple{
update: listenerUpdate{
RouteConfigName: routeConfigName2,
},
}
if err := verifyListenerUpdate(ctx, lw2.updateCh, wantListenerUpdate); err != nil {
t.Fatal(err)
}
// Cancel the watch for the second listener resource, and verify that an LDS
// request with only first listener resource names is sent.
ldsCancel2()
if err := waitForResourceNames(ctx, t, ldsResourcesCh, []string{listenerName}); err != nil {
t.Fatal(err)
}
// Stop the restartable listener and wait for the stream to close.
lis.Stop()
select {
case <-streamClosed:
case <-ctx.Done():
t.Fatal("Timeout when waiting for ADS stream to close")
}
// Restart the restartable listener and wait for the stream to open.
lis.Restart()
select {
case <-streamOpened:
case <-ctx.Done():
t.Fatal("Timeout when waiting for ADS stream to open")
}
// Verify that the first listener resource is requested again.
if err := waitForResourceNames(ctx, t, ldsResourcesCh, []string{listenerName}); err != nil {
t.Fatal(err)
}
// Wait for a short duration and verify that no LDS request is sent, since
// there are no resources being watched.
sCtx, sCancel := context.WithTimeout(ctx, defaultTestShortTimeout)
defer sCancel()
select {
case <-sCtx.Done():
case names := <-ldsResourcesCh:
t.Fatalf("LDS request sent for resource names %v, when expecting no request", names)
}
}

View File

@@ -0,0 +1,222 @@
/*
*
* Copyright 2024 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package xdsclient_test
import (
"context"
"fmt"
"net"
"testing"
"time"
"github.com/google/uuid"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/internal/testutils/xds/e2e"
"google.golang.org/grpc/internal/xds/clients"
"google.golang.org/grpc/internal/xds/clients/grpctransport"
"google.golang.org/grpc/internal/xds/clients/internal/testutils"
"google.golang.org/grpc/internal/xds/clients/xdsclient"
xdsclientinternal "google.golang.org/grpc/internal/xds/clients/xdsclient/internal"
"google.golang.org/grpc/internal/xds/clients/xdsclient/internal/xdsresource"
v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
)
func waitForResourceWatchState(ctx context.Context, client *xdsclient.XDSClient, resourceName string, wantState xdsresource.WatchState, wantTimer bool) error {
var lastErr error
for ; ctx.Err() == nil; <-time.After(defaultTestShortTimeout) {
err := verifyResourceWatchState(client, resourceName, wantState, wantTimer)
if err == nil {
break
}
lastErr = err
}
if ctx.Err() != nil {
return fmt.Errorf("timeout when waiting for expected watch state for resource %q: %v", resourceName, lastErr)
}
return nil
}
func verifyResourceWatchState(client *xdsclient.XDSClient, resourceName string, wantState xdsresource.WatchState, wantTimer bool) error {
resourceWatchStateForTesting := xdsclientinternal.ResourceWatchStateForTesting.(func(*xdsclient.XDSClient, xdsclient.ResourceType, string) (xdsresource.ResourceWatchState, error))
gotState, err := resourceWatchStateForTesting(client, listenerType, resourceName)
if err != nil {
return fmt.Errorf("failed to get watch state for resource %q: %v", resourceName, err)
}
if gotState.State != wantState {
return fmt.Errorf("watch state for resource %q is %v, want %v", resourceName, gotState.State, wantState)
}
if (gotState.ExpiryTimer != nil) != wantTimer {
return fmt.Errorf("expiry timer for resource %q is %t, want %t", resourceName, gotState.ExpiryTimer != nil, wantTimer)
}
return nil
}
// Tests the state transitions of the resource specific watch state within the
// ADS stream, specifically when the stream breaks (for both resources that have
// been previously received and for resources that are yet to be received).
func (s) TestADS_WatchState_StreamBreaks(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Create an xDS management server with a restartable listener.
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("net.Listen() failed: %v", err)
}
lis := testutils.NewRestartableListener(l)
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{Listener: lis})
// Create an xDS client pointing to the above server.
nodeID := uuid.New().String()
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
client := createXDSClient(t, mgmtServer.Address, nodeID, grpctransport.NewBuilder(configs))
// Create a watch for the first listener resource and verify that the timer
// is running and the watch state is `requested`.
const listenerName1 = "listener1"
ldsCancel1 := client.WatchResource(xdsresource.V3ListenerURL, listenerName1, noopListenerWatcher{})
defer ldsCancel1()
if err := waitForResourceWatchState(ctx, client, listenerName1, xdsresource.ResourceWatchStateRequested, true); err != nil {
t.Fatal(err)
}
// Configure the first resource on the management server. This should result
// in the resource being pushed to the xDS client and should result in the
// timer getting stopped and the watch state moving to `received`.
const routeConfigName = "route-config"
listenerResource1 := e2e.DefaultClientListener(listenerName1, routeConfigName)
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{listenerResource1},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}
if err := waitForResourceWatchState(ctx, client, listenerName1, xdsresource.ResourceWatchStateReceived, false); err != nil {
t.Fatal(err)
}
// Create a watch for the second listener resource and verify that the timer
// is running and the watch state is `requested`.
const listenerName2 = "listener2"
ldsCancel2 := client.WatchResource(xdsresource.V3ListenerURL, listenerName2, noopListenerWatcher{})
defer ldsCancel2()
if err := waitForResourceWatchState(ctx, client, listenerName2, xdsresource.ResourceWatchStateRequested, true); err != nil {
t.Fatal(err)
}
// Stop the server to break the ADS stream. Since the first resource was
// already received, this should not change anything for it. But for the
// second resource, it should result in the timer getting stopped and the
// watch state moving to `started`.
lis.Stop()
if err := waitForResourceWatchState(ctx, client, listenerName2, xdsresource.ResourceWatchStateStarted, false); err != nil {
t.Fatal(err)
}
if err := verifyResourceWatchState(client, listenerName1, xdsresource.ResourceWatchStateReceived, false); err != nil {
t.Fatal(err)
}
// Restart the server and verify that the timer is running and the watch
// state is `requested`, for the second resource. For the first resource,
// nothing should change.
lis.Restart()
if err := waitForResourceWatchState(ctx, client, listenerName2, xdsresource.ResourceWatchStateRequested, true); err != nil {
t.Fatal(err)
}
if err := verifyResourceWatchState(client, listenerName1, xdsresource.ResourceWatchStateReceived, false); err != nil {
t.Fatal(err)
}
// Configure the second resource on the management server. This should result
// in the resource being pushed to the xDS client and should result in the
// timer getting stopped and the watch state moving to `received`.
listenerResource2 := e2e.DefaultClientListener(listenerName2, routeConfigName)
resources = e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{listenerResource1, listenerResource2},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatal(err)
}
if err := waitForResourceWatchState(ctx, client, listenerName2, xdsresource.ResourceWatchStateReceived, false); err != nil {
t.Fatal(err)
}
}
// Tests the behavior of the xDS client when a resource watch timer expires and
// verifies the resource watch state transitions as expected.
func (s) TestADS_WatchState_TimerFires(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Start an xDS management server.
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{})
// Create an xDS client with bootstrap pointing to the above server, and a
// short resource expiry timeout.
nodeID := uuid.New().String()
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
resourceTypes := map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType}
si := clients.ServerIdentifier{
ServerURI: mgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{ServerIdentifier: si}},
Node: clients.Node{ID: nodeID, UserAgentName: "user-agent", UserAgentVersion: "0.0.0.0"},
TransportBuilder: grpctransport.NewBuilder(configs),
ResourceTypes: resourceTypes,
// Xdstp resource names used in this test do not specify an
// authority. These will end up looking up an entry with the
// empty key in the authorities map. Having an entry with an
// empty key and empty configuration, results in these
// resources also using the top-level configuration.
Authorities: map[string]xdsclient.Authority{
"": {XDSServers: []xdsclient.ServerConfig{}},
},
WatchExpiryTimeout: defaultTestWatchExpiryTimeout,
}
// Create an xDS client with the above config.
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
t.Cleanup(func() { client.Close() })
// Create a watch for the first listener resource and verify that the timer
// is running and the watch state is `requested`.
const listenerName = "listener"
ldsCancel1 := client.WatchResource(xdsresource.V3ListenerURL, listenerName, noopListenerWatcher{})
defer ldsCancel1()
if err := waitForResourceWatchState(ctx, client, listenerName, xdsresource.ResourceWatchStateRequested, true); err != nil {
t.Fatal(err)
}
// Since the resource is not configured on the management server, the watch
// expiry timer is expected to fire, and the watch state should move to
// `timeout`.
if err := waitForResourceWatchState(ctx, client, listenerName, xdsresource.ResourceWatchStateTimeout, false); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,334 @@
/*
*
* Copyright 2022 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package xdsclient_test
import (
"context"
"net"
"testing"
"github.com/google/uuid"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/internal/xds/clients"
"google.golang.org/grpc/internal/xds/clients/grpctransport"
"google.golang.org/grpc/internal/xds/clients/internal/testutils"
"google.golang.org/grpc/internal/xds/clients/internal/testutils/e2e"
"google.golang.org/grpc/internal/xds/clients/xdsclient"
"google.golang.org/grpc/internal/xds/clients/xdsclient/internal/xdsresource"
v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
)
const (
testAuthority1 = "test-authority1"
testAuthority2 = "test-authority2"
testAuthority3 = "test-authority3"
)
var (
// These two resources use `testAuthority1`, which contains an empty server
// config and therefore will use the default management server.
authorityTestResourceName11 = buildResourceName(listenerResourceTypeName, testAuthority1, ldsName, nil)
authorityTestResourceName12 = buildResourceName(listenerResourceTypeName, testAuthority1, ldsName+"2", nil)
// This resource uses `testAuthority2`, which contains an empty server
// config and therefore will use the default management server.
authorityTestResourceName2 = buildResourceName(listenerResourceTypeName, testAuthority2, ldsName+"3", nil)
// This resource uses `testAuthority3`, which contains a non-empty server
// config, and therefore will use the non-default management server.
authorityTestResourceName3 = buildResourceName(listenerResourceTypeName, testAuthority3, ldsName+"3", nil)
)
// setupForAuthorityTests spins up two management servers, one to act as the
// default and the other to act as the non-default. It also creates a
// xDS client configuration with three authorities (the first two pointing to
// the default and the third one pointing to the non-default).
//
// Returns two listeners used by the default and non-default management servers
// respectively, and the xDS client.
func setupForAuthorityTests(ctx context.Context, t *testing.T) (*testutils.ListenerWrapper, *testutils.ListenerWrapper, *xdsclient.XDSClient) {
// Create listener wrappers which notify on to a channel whenever a new
// connection is accepted. We use this to track the number of transports
// used by the xDS client.
lisDefault := testutils.NewListenerWrapper(t, nil)
lisNonDefault := testutils.NewListenerWrapper(t, nil)
// Start a management server to act as the default authority.
defaultAuthorityServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{Listener: lisDefault})
// Start a management server to act as the non-default authority.
nonDefaultAuthorityServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{Listener: lisNonDefault})
// Create a bootstrap configuration with two non-default authorities which
// have empty server configs, and therefore end up using the default server
// config, which points to the above management server.
nodeID := uuid.New().String()
resourceTypes := map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType}
ext := grpctransport.ServerIdentifierExtension{ConfigName: "insecure"}
siDefault := clients.ServerIdentifier{
ServerURI: defaultAuthorityServer.Address,
Extensions: ext,
}
siNonDefault := clients.ServerIdentifier{
ServerURI: nonDefaultAuthorityServer.Address,
Extensions: ext,
}
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{ServerIdentifier: siDefault}},
Node: clients.Node{ID: nodeID},
TransportBuilder: grpctransport.NewBuilder(configs),
ResourceTypes: resourceTypes,
// Xdstp style resource names used in this test use a slash removed
// version of t.Name as their authority, and the empty config
// results in the top-level xds server configuration being used for
// this authority.
Authorities: map[string]xdsclient.Authority{
testAuthority1: {XDSServers: []xdsclient.ServerConfig{}},
testAuthority2: {XDSServers: []xdsclient.ServerConfig{}},
testAuthority3: {XDSServers: []xdsclient.ServerConfig{{ServerIdentifier: siNonDefault}}},
},
WatchExpiryTimeout: defaultTestWatchExpiryTimeout,
}
// Create an xDS client with the above config.
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{
e2e.DefaultClientListener(authorityTestResourceName11, rdsName),
e2e.DefaultClientListener(authorityTestResourceName12, rdsName),
e2e.DefaultClientListener(authorityTestResourceName2, rdsName),
e2e.DefaultClientListener(authorityTestResourceName3, rdsName),
},
SkipValidation: true,
}
if err := defaultAuthorityServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server with resources: %v, err: %v", resources, err)
}
return lisDefault, lisNonDefault, client
}
// Tests the xdsChannel sharing logic among authorities. The test verifies the
// following scenarios:
// - A watch for a resource name with an authority matching an existing watch
// should not result in a new transport being created.
// - A watch for a resource name with different authority name but same
// authority config as an existing watch should not result in a new transport
// being created.
func (s) TestAuthority_XDSChannelSharing(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
lis, _, client := setupForAuthorityTests(ctx, t)
defer client.Close()
// Verify that no connection is established to the management server at this
// point. A transport is created only when a resource (which belongs to that
// authority) is requested.
sCtx, sCancel := context.WithTimeout(ctx, defaultTestShortTimeout)
defer sCancel()
if _, err := lis.NewConnCh.Receive(sCtx); err != context.DeadlineExceeded {
t.Fatal("Unexpected new transport created to management server")
}
// Request the first resource. Verify that a new transport is created.
watcher := noopListenerWatcher{}
ldsCancel1 := client.WatchResource(xdsresource.V3ListenerURL, authorityTestResourceName11, watcher)
defer ldsCancel1()
if _, err := lis.NewConnCh.Receive(ctx); err != nil {
t.Fatalf("Timed out when waiting for a new transport to be created to the management server: %v", err)
}
// Request the second resource. Verify that no new transport is created.
ldsCancel2 := client.WatchResource(xdsresource.V3ListenerURL, authorityTestResourceName12, watcher)
defer ldsCancel2()
sCtx, sCancel = context.WithTimeout(ctx, defaultTestShortTimeout)
defer sCancel()
if _, err := lis.NewConnCh.Receive(sCtx); err != context.DeadlineExceeded {
t.Fatal("Unexpected new transport created to management server")
}
// Request the third resource. Verify that no new transport is created.
ldsCancel3 := client.WatchResource(xdsresource.V3ListenerURL, authorityTestResourceName2, watcher)
defer ldsCancel3()
sCtx, sCancel = context.WithTimeout(ctx, defaultTestShortTimeout)
defer sCancel()
if _, err := lis.NewConnCh.Receive(sCtx); err != context.DeadlineExceeded {
t.Fatal("Unexpected new transport created to management server")
}
}
// Test the xdsChannel close logic. The test verifies that the xDS client
// closes an xdsChannel immediately after the last watch is canceled.
func (s) TestAuthority_XDSChannelClose(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
lis, _, client := setupForAuthorityTests(ctx, t)
defer client.Close()
// Request the first resource. Verify that a new transport is created.
watcher := noopListenerWatcher{}
ldsCancel1 := client.WatchResource(xdsresource.V3ListenerURL, authorityTestResourceName11, watcher)
val, err := lis.NewConnCh.Receive(ctx)
if err != nil {
t.Fatalf("Timed out when waiting for a new transport to be created to the management server: %v", err)
}
conn := val.(*testutils.ConnWrapper)
// Request the second resource. Verify that no new transport is created.
ldsCancel2 := client.WatchResource(xdsresource.V3ListenerURL, authorityTestResourceName12, watcher)
sCtx, sCancel := context.WithTimeout(ctx, defaultTestShortTimeout)
defer sCancel()
if _, err := lis.NewConnCh.Receive(sCtx); err != context.DeadlineExceeded {
t.Fatal("Unexpected new transport created to management server")
}
// Cancel both watches, and verify that the connection to the management
// server is closed.
ldsCancel1()
ldsCancel2()
if _, err := conn.CloseCh.Receive(ctx); err != nil {
t.Fatal("Timeout when waiting for connection to management server to be closed")
}
}
// Tests the scenario where the primary management server is unavailable at
// startup and the xDS client falls back to the secondary. The test verifies
// that the resource watcher is not notified of the connectivity failure until
// all servers have failed.
func (s) TestAuthority_Fallback(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Create primary and secondary management servers with restartable
// listeners.
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("net.Listen() failed: %v", err)
}
primaryLis := testutils.NewRestartableListener(l)
primaryMgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{Listener: primaryLis})
l, err = net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("net.Listen() failed: %v", err)
}
secondaryLis := testutils.NewRestartableListener(l)
secondaryMgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{Listener: secondaryLis})
nodeID := uuid.New().String()
resourceTypes := map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType}
psi := clients.ServerIdentifier{
ServerURI: primaryMgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
ssi := clients.ServerIdentifier{
ServerURI: secondaryMgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
// Create config with the above primary and fallback management servers,
// and an xDS client with that configuration.
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{ServerIdentifier: psi}, {ServerIdentifier: ssi}},
Node: clients.Node{ID: nodeID},
TransportBuilder: grpctransport.NewBuilder(configs),
ResourceTypes: resourceTypes,
// Xdstp resource names used in this test do not specify an
// authority. These will end up looking up an entry with the
// empty key in the authorities map. Having an entry with an
// empty key and empty configuration, results in these
// resources also using the top-level configuration.
Authorities: map[string]xdsclient.Authority{
"": {XDSServers: []xdsclient.ServerConfig{}},
},
}
// Create an xDS client with the above config.
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client.Close()
const listenerName = "listener"
const rdsPrimaryName = "rds-primary"
const rdsSecondaryName = "rds-secondary"
// Create a Cluster resource on the primary.
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{e2e.DefaultClientListener(listenerName, rdsPrimaryName)},
SkipValidation: true,
}
if err := primaryMgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update primary management server with resources: %v, err: %v", resources, err)
}
// Create a Cluster resource on the secondary .
resources = e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{e2e.DefaultClientListener(listenerName, rdsSecondaryName)},
SkipValidation: true,
}
if err := secondaryMgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update primary management server with resources: %v, err: %v", resources, err)
}
// Stop the primary.
primaryLis.Close()
// Register a watch.
watcher := newListenerWatcher()
ldsCancel := client.WatchResource(xdsresource.V3ListenerURL, listenerName, watcher)
defer ldsCancel()
// Ensure that the connectivity error callback is not called. Since, this
// is the first watch without cached resource, it checks for resourceErrCh
sCtx, sCancel := context.WithTimeout(ctx, defaultTestShortTimeout)
defer sCancel()
if v, err := watcher.resourceErrCh.Receive(sCtx); err != context.DeadlineExceeded {
t.Fatalf("Resource error callback on the watcher with error: %v", v.(error))
}
// Ensure that the resource update callback is invoked.
wantUpdate := listenerUpdateErrTuple{
update: listenerUpdate{
RouteConfigName: rdsSecondaryName,
},
}
if err := verifyListenerUpdate(ctx, watcher.updateCh, wantUpdate); err != nil {
t.Fatal(err)
}
// Stop the secondary.
secondaryLis.Close()
// Ensure that the connectivity error callback is called as ambient error
// since cached resource exist.
if _, err := watcher.ambientErrCh.Receive(ctx); err != nil {
t.Fatal("Timeout when waiting for ambient error callback on the watcher")
}
}

View File

@@ -0,0 +1,254 @@
/*
*
* Copyright 2026 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package xdsclient_test
import (
"context"
"fmt"
"strings"
"testing"
"github.com/google/uuid"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/internal/xds/clients"
"google.golang.org/grpc/internal/xds/clients/grpctransport"
"google.golang.org/grpc/internal/xds/clients/internal/testutils/fakeserver"
"google.golang.org/grpc/internal/xds/clients/xdsclient"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/wrapperspb"
v3discoverypb "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3"
)
// customTestResourceType is a fake resource type used for testing.
var customTestResourceType = xdsclient.ResourceType{
TypeURL: "type.googleapis.com/google.protobuf.StringValue",
TypeName: "CustomResource",
AllResourcesRequiredInSotW: false,
Decoder: customTestDecoder{},
}
type customTestDecoder struct{}
func (customTestDecoder) Decode(resource *xdsclient.AnyProto, _ xdsclient.DecodeOptions) (*xdsclient.DecodeResult, error) {
any := resource.ToAny()
if any.GetTypeUrl() != customTestResourceType.TypeURL {
return nil, fmt.Errorf("unexpected resource type: %q", any.GetTypeUrl())
}
var val wrapperspb.StringValue
if err := proto.Unmarshal(any.GetValue(), &val); err != nil {
return nil, fmt.Errorf("failed to unmarshal resource: %v", err)
}
// We expect the value to be "name|content". This allows us to extract the
// name which is required by the xDS client to match the watch.
str := val.GetValue()
name, content, found := strings.Cut(str, "|")
if !found {
return nil, fmt.Errorf("invalid format: expected 'name|content', got %q", str)
}
return &xdsclient.DecodeResult{
Name: name,
Resource: &customTestResourceData{val: content},
}, nil
}
type customTestResourceData struct {
val string
}
func (c *customTestResourceData) Equal(other xdsclient.ResourceData) bool {
if c == nil && other == nil {
return true
}
if c == nil || other == nil {
return false
}
o, ok := other.(*customTestResourceData)
if !ok {
return false
}
return c.val == o.val
}
func (c *customTestResourceData) Bytes() []byte {
return []byte(c.val)
}
// customTestWatcher is a watcher for the custom resource type.
type customTestWatcher struct {
updateCh chan xdsclient.ResourceData
errCh chan error
}
func newCustomTestWatcher() *customTestWatcher {
return &customTestWatcher{
updateCh: make(chan xdsclient.ResourceData, 1),
errCh: make(chan error, 1),
}
}
func (w *customTestWatcher) ResourceChanged(update xdsclient.ResourceData, onDone func()) {
w.updateCh <- update
onDone()
}
func (w *customTestWatcher) ResourceError(err error, onDone func()) {
w.errCh <- fmt.Errorf("ResourceError: %v", err)
onDone()
}
func (w *customTestWatcher) AmbientError(err error, onDone func()) {
w.errCh <- fmt.Errorf("AmbientError: %v", err)
onDone()
}
// Tests that the xDS client can watch a custom resource type that is injected
// via the config.
func (s) TestCustomResourceWatch(t *testing.T) {
const authority = "my-authority"
resourceName := "xdstp://" + authority + "/" + customTestResourceType.TypeURL + "/my-resource"
tests := []struct {
name string
resourceValue string
wantUpdate string
wantNACK string
}{
{
name: "valid_resource",
resourceValue: resourceName + "|hello world",
wantUpdate: "hello world",
},
{
name: "decode_error",
resourceValue: "malformed-value",
wantNACK: "invalid format: expected 'name|content'",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Start a fake xDS management server.
mgmtServer, cleanup, err := fakeserver.StartServer(nil)
if err != nil {
t.Fatalf("Failed to start fake xDS server: %v", err)
}
defer cleanup()
resourceTypes := map[string]xdsclient.ResourceType{
customTestResourceType.TypeURL: customTestResourceType,
}
si := clients.ServerIdentifier{
ServerURI: mgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
nodeID := uuid.New().String()
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{ServerIdentifier: si}},
Node: clients.Node{ID: nodeID},
TransportBuilder: grpctransport.NewBuilder(configs),
ResourceTypes: resourceTypes,
Authorities: map[string]xdsclient.Authority{
authority: {XDSServers: []xdsclient.ServerConfig{}},
},
}
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client.Close()
watcher := newCustomTestWatcher()
cancelWatch := client.WatchResource(customTestResourceType.TypeURL, resourceName, watcher)
defer cancelWatch()
// Wait for the discovery request.
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Verify the request type URL.
v, err := mgmtServer.XDSRequestChan.Receive(ctx)
if err != nil {
t.Fatalf("Timeout when waiting for a DiscoveryRequest message: %v", err)
}
req := v.(*fakeserver.Request).Req.(*v3discoverypb.DiscoveryRequest)
if req.GetTypeUrl() != customTestResourceType.TypeURL {
t.Fatalf("DiscoveryRequest TypeUrl = %v, want %v", req.GetTypeUrl(), customTestResourceType.TypeURL)
}
if len(req.GetResourceNames()) != 1 || req.GetResourceNames()[0] != resourceName {
t.Fatalf("DiscoveryRequest ResourceNames = %v, want [%v]", req.GetResourceNames(), resourceName)
}
// Send a response with the custom resource.
resource, err := anypb.New(&wrapperspb.StringValue{Value: test.resourceValue})
if err != nil {
t.Fatalf("Failed to marshal resource: %v", err)
}
mgmtServer.XDSResponseChan <- &fakeserver.Response{
Resp: &v3discoverypb.DiscoveryResponse{
TypeUrl: customTestResourceType.TypeURL,
VersionInfo: "1",
Resources: []*anypb.Any{resource},
},
}
if test.wantNACK != "" {
// Verify the NACK.
// We expect a new DiscoveryRequest with ErrorDetail set.
v, err = mgmtServer.XDSRequestChan.Receive(ctx)
if err != nil {
t.Fatalf("Timeout when waiting for NACK DiscoveryRequest message: %v", err)
}
req = v.(*fakeserver.Request).Req.(*v3discoverypb.DiscoveryRequest)
if req.GetTypeUrl() != customTestResourceType.TypeURL {
t.Fatalf("DiscoveryRequest TypeUrl = %v, want %v", req.GetTypeUrl(), customTestResourceType.TypeURL)
}
if req.GetErrorDetail() == nil {
t.Fatalf("DiscoveryRequest ErrorDetail is nil, want non-nil")
}
if !strings.Contains(req.GetErrorDetail().GetMessage(), test.wantNACK) {
t.Fatalf("DiscoveryRequest ErrorDetail = %v, want substring %q", req.GetErrorDetail().GetMessage(), test.wantNACK)
}
return
}
// Verify the update.
select {
case <-ctx.Done():
t.Fatalf("Timeout waiting for resource update")
case err := <-watcher.errCh:
t.Fatalf("Received unexpected error: %v", err)
case got := <-watcher.updateCh:
gotData, ok := got.(*customTestResourceData)
if !ok {
t.Fatalf("Received unexpected data type: %T", got)
}
if gotData.val != test.wantUpdate {
t.Fatalf("Received resource value %q, want %q", gotData.val, test.wantUpdate)
}
}
})
}
}

View File

@@ -0,0 +1,557 @@
/*
*
* Copyright 2022 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package xdsclient_test
import (
"context"
"fmt"
"slices"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/internal/xds/clients"
"google.golang.org/grpc/internal/xds/clients/grpctransport"
"google.golang.org/grpc/internal/xds/clients/internal/pretty"
"google.golang.org/grpc/internal/xds/clients/internal/testutils"
"google.golang.org/grpc/internal/xds/clients/internal/testutils/e2e"
"google.golang.org/grpc/internal/xds/clients/xdsclient"
"google.golang.org/grpc/internal/xds/clients/xdsclient/internal/xdsresource"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/testing/protocmp"
"google.golang.org/protobuf/types/known/anypb"
v3adminpb "github.com/envoyproxy/go-control-plane/envoy/admin/v3"
v3corepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
v3httppb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
v3statuspb "github.com/envoyproxy/go-control-plane/envoy/service/status/v3"
)
func makeGenericXdsConfig(typeURL, name, version string, status v3adminpb.ClientResourceStatus, config *anypb.Any, failure *v3adminpb.UpdateFailureState) *v3statuspb.ClientConfig_GenericXdsConfig {
return &v3statuspb.ClientConfig_GenericXdsConfig{
TypeUrl: typeURL,
Name: name,
VersionInfo: version,
ClientStatus: status,
XdsConfig: config,
ErrorState: failure,
}
}
func checkResourceDump(ctx context.Context, want *v3statuspb.ClientStatusResponse, client *xdsclient.XDSClient) error {
var cmpOpts = cmp.Options{
protocmp.Transform(),
protocmp.IgnoreFields((*v3statuspb.ClientConfig_GenericXdsConfig)(nil), "last_updated"),
protocmp.IgnoreFields((*v3statuspb.ClientConfig)(nil), "client_scope"),
protocmp.IgnoreFields((*v3adminpb.UpdateFailureState)(nil), "last_update_attempt", "details"),
}
var lastErr error
for ; ctx.Err() == nil; <-time.After(defaultTestShortTimeout) {
b, err := client.DumpResources()
if err != nil {
lastErr = err
continue
}
got := &v3statuspb.ClientStatusResponse{}
if err := proto.Unmarshal(b, got); err != nil {
lastErr = err
continue
}
// Sort the client configs based on the `client_scope` field.
slices.SortFunc(got.GetConfig(), func(a, b *v3statuspb.ClientConfig) int {
return strings.Compare(a.ClientScope, b.ClientScope)
})
// Sort the resource configs based on the type_url and name fields.
for _, cc := range got.GetConfig() {
slices.SortFunc(cc.GetGenericXdsConfigs(), func(a, b *v3statuspb.ClientConfig_GenericXdsConfig) int {
if strings.Compare(a.TypeUrl, b.TypeUrl) == 0 {
return strings.Compare(a.Name, b.Name)
}
return strings.Compare(a.TypeUrl, b.TypeUrl)
})
}
diff := cmp.Diff(want, got, cmpOpts)
if diff == "" {
return nil
}
lastErr = fmt.Errorf("received unexpected resource dump, diff (-got, +want):\n%s, got: %s\n want:%s", diff, pretty.ToJSON(got), pretty.ToJSON(want))
}
return fmt.Errorf("timeout when waiting for resource dump to reach expected state: %v", lastErr)
}
// Tests the scenario where there are multiple xDS clients talking to the same
// management server, and requesting the same set of resources. Verifies that
// under all circumstances, both xDS clients receive the same configuration from
// the server.
func (s) TestDumpResources_ManyToOne(t *testing.T) {
// Initialize the xDS resources to be used in this test.
ldsTargets := []string{"lds.target.good:0000", "lds.target.good:1111"}
rdsTargets := []string{"route-config-0", "route-config-1"}
listeners := make([]*v3listenerpb.Listener, len(ldsTargets))
listenerAnys := make([]*anypb.Any, len(ldsTargets))
for i := range ldsTargets {
listeners[i] = e2e.DefaultClientListener(ldsTargets[i], rdsTargets[i])
listenerAnys[i] = testutils.MarshalAny(t, listeners[i])
}
// Spin up an xDS management server on a local port.
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{})
nodeID := uuid.New().String()
resourceTypes := map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType}
si := clients.ServerIdentifier{
ServerURI: mgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{ServerIdentifier: si}},
Node: clients.Node{ID: nodeID, UserAgentName: "user-agent", UserAgentVersion: "0.0.0.0"},
TransportBuilder: grpctransport.NewBuilder(configs),
ResourceTypes: resourceTypes,
// Xdstp resource names used in this test do not specify an
// authority. These will end up looking up an entry with the
// empty key in the authorities map. Having an entry with an
// empty key and empty configuration, results in these
// resources also using the top-level configuration.
Authorities: map[string]xdsclient.Authority{
"": {XDSServers: []xdsclient.ServerConfig{}},
},
}
// Create two xDS clients with the above config.
client1, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client1.Close()
client2, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client2.Close()
// Dump resources and expect empty configs.
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
wantNode := &v3corepb.Node{
Id: nodeID,
UserAgentName: "user-agent",
UserAgentVersionType: &v3corepb.Node_UserAgentVersion{UserAgentVersion: "0.0.0.0"},
ClientFeatures: []string{"envoy.lb.does_not_support_overprovisioning", "xds.config.resource-in-sotw"},
}
wantResp := &v3statuspb.ClientStatusResponse{
Config: []*v3statuspb.ClientConfig{
{
Node: wantNode,
},
},
}
if err := checkResourceDump(ctx, wantResp, client1); err != nil {
t.Fatal(err)
}
if err := checkResourceDump(ctx, wantResp, client2); err != nil {
t.Fatal(err)
}
// Register watches, dump resources and expect configs in requested state.
for _, xdsC := range []*xdsclient.XDSClient{client1, client2} {
for _, target := range ldsTargets {
xdsC.WatchResource(xdsresource.V3ListenerURL, target, noopListenerWatcher{})
}
}
wantConfigs := []*v3statuspb.ClientConfig_GenericXdsConfig{
makeGenericXdsConfig("type.googleapis.com/envoy.config.listener.v3.Listener", ldsTargets[0], "", v3adminpb.ClientResourceStatus_REQUESTED, nil, nil),
makeGenericXdsConfig("type.googleapis.com/envoy.config.listener.v3.Listener", ldsTargets[1], "", v3adminpb.ClientResourceStatus_REQUESTED, nil, nil),
}
wantResp = &v3statuspb.ClientStatusResponse{
Config: []*v3statuspb.ClientConfig{
{
Node: wantNode,
GenericXdsConfigs: wantConfigs,
},
},
}
if err := checkResourceDump(ctx, wantResp, client1); err != nil {
t.Fatal(err)
}
if err := checkResourceDump(ctx, wantResp, client2); err != nil {
t.Fatal(err)
}
// Configure the resources on the management server.
if err := mgmtServer.Update(ctx, e2e.UpdateOptions{
NodeID: nodeID,
Listeners: listeners,
SkipValidation: true,
}); err != nil {
t.Fatal(err)
}
// Dump resources and expect ACK configs.
wantConfigs = []*v3statuspb.ClientConfig_GenericXdsConfig{
makeGenericXdsConfig("type.googleapis.com/envoy.config.listener.v3.Listener", ldsTargets[0], "1", v3adminpb.ClientResourceStatus_ACKED, listenerAnys[0], nil),
makeGenericXdsConfig("type.googleapis.com/envoy.config.listener.v3.Listener", ldsTargets[1], "1", v3adminpb.ClientResourceStatus_ACKED, listenerAnys[1], nil),
}
wantResp = &v3statuspb.ClientStatusResponse{
Config: []*v3statuspb.ClientConfig{
{
Node: wantNode,
GenericXdsConfigs: wantConfigs,
},
},
}
if err := checkResourceDump(ctx, wantResp, client1); err != nil {
t.Fatal(err)
}
if err := checkResourceDump(ctx, wantResp, client2); err != nil {
t.Fatal(err)
}
// Update the first resource of each type in the management server to a
// value which is expected to be NACK'ed by the xDS client.
listeners[0] = func() *v3listenerpb.Listener {
hcm := testutils.MarshalAny(t, &v3httppb.HttpConnectionManager{})
return &v3listenerpb.Listener{
Name: ldsTargets[0],
ApiListener: &v3listenerpb.ApiListener{ApiListener: hcm},
}
}()
if err := mgmtServer.Update(ctx, e2e.UpdateOptions{
NodeID: nodeID,
Listeners: listeners,
SkipValidation: true,
}); err != nil {
t.Fatal(err)
}
wantConfigs = []*v3statuspb.ClientConfig_GenericXdsConfig{
makeGenericXdsConfig("type.googleapis.com/envoy.config.listener.v3.Listener", ldsTargets[0], "1", v3adminpb.ClientResourceStatus_NACKED, listenerAnys[0], &v3adminpb.UpdateFailureState{VersionInfo: "2"}),
makeGenericXdsConfig("type.googleapis.com/envoy.config.listener.v3.Listener", ldsTargets[1], "2", v3adminpb.ClientResourceStatus_ACKED, listenerAnys[1], nil),
}
wantResp = &v3statuspb.ClientStatusResponse{
Config: []*v3statuspb.ClientConfig{
{
Node: wantNode,
GenericXdsConfigs: wantConfigs,
},
},
}
if err := checkResourceDump(ctx, wantResp, client1); err != nil {
t.Fatal(err)
}
if err := checkResourceDump(ctx, wantResp, client2); err != nil {
t.Fatal(err)
}
}
// Tests the scenario where there are multiple xDS client talking to different
// management server, and requesting different set of resources.
func (s) TestDumpResources_ManyToMany(t *testing.T) {
// Initialize the xDS resources to be used in this test:
// - The first xDS client watches old style resource names, and thereby
// requests these resources from the top-level xDS server.
// - The second xDS client watches new style resource names with a non-empty
// authority, and thereby requests these resources from the server
// configuration for that authority.
authority := strings.Join(strings.Split(t.Name(), "/"), "")
ldsTargets := []string{
"lds.target.good:0000",
fmt.Sprintf("xdstp://%s/envoy.config.listener.v3.Listener/lds.targer.good:1111", authority),
}
rdsTargets := []string{
"route-config-0",
fmt.Sprintf("xdstp://%s/envoy.config.route.v3.RouteConfiguration/route-config-1", authority),
}
listeners := make([]*v3listenerpb.Listener, len(ldsTargets))
listenerAnys := make([]*anypb.Any, len(ldsTargets))
for i := range ldsTargets {
listeners[i] = e2e.DefaultClientListener(ldsTargets[i], rdsTargets[i])
listenerAnys[i] = testutils.MarshalAny(t, listeners[i])
}
// Start two management servers.
mgmtServer1 := e2e.StartManagementServer(t, e2e.ManagementServerOptions{})
mgmtServer2 := e2e.StartManagementServer(t, e2e.ManagementServerOptions{})
// The first of the above management servers will be the top-level xDS
// server in the bootstrap configuration, and the second will be the xDS
// server corresponding to the test authority.
nodeID := uuid.New().String()
resourceTypes := map[string]xdsclient.ResourceType{}
listenerType := listenerType
resourceTypes[xdsresource.V3ListenerURL] = listenerType
si1 := clients.ServerIdentifier{
ServerURI: mgmtServer1.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
si2 := clients.ServerIdentifier{
ServerURI: mgmtServer2.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{ServerIdentifier: si1}},
Node: clients.Node{ID: nodeID, UserAgentName: "user-agent", UserAgentVersion: "0.0.0.0"},
TransportBuilder: grpctransport.NewBuilder(configs),
ResourceTypes: resourceTypes,
// Xdstp style resource names used in this test use a slash removed
// version of t.Name as their authority, and the empty config
// results in the top-level xds server configuration being used for
// this authority.
Authorities: map[string]xdsclient.Authority{
authority: {XDSServers: []xdsclient.ServerConfig{{ServerIdentifier: si2}}},
},
}
// Create two xDS clients with the above config.
client1, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client1.Close()
client2, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client2.Close()
// Check the resource dump before configuring resources on the management server.
// Dump resources and expect empty configs.
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
wantNode := &v3corepb.Node{
Id: nodeID,
UserAgentName: "user-agent",
UserAgentVersionType: &v3corepb.Node_UserAgentVersion{UserAgentVersion: "0.0.0.0"},
ClientFeatures: []string{"envoy.lb.does_not_support_overprovisioning", "xds.config.resource-in-sotw"},
}
wantResp := &v3statuspb.ClientStatusResponse{
Config: []*v3statuspb.ClientConfig{
{
Node: wantNode,
},
},
}
if err := checkResourceDump(ctx, wantResp, client1); err != nil {
t.Fatal(err)
}
if err := checkResourceDump(ctx, wantResp, client2); err != nil {
t.Fatal(err)
}
// Register watches, the first xDS client watches old style resource names,
// while the second xDS client watches new style resource names.
client1.WatchResource(xdsresource.V3ListenerURL, ldsTargets[0], noopListenerWatcher{})
client2.WatchResource(xdsresource.V3ListenerURL, ldsTargets[1], noopListenerWatcher{})
// Check the resource dump. Both clients should have all resources in
// REQUESTED state.
wantConfigs1 := []*v3statuspb.ClientConfig_GenericXdsConfig{
makeGenericXdsConfig("type.googleapis.com/envoy.config.listener.v3.Listener", ldsTargets[0], "", v3adminpb.ClientResourceStatus_REQUESTED, nil, nil),
}
wantConfigs2 := []*v3statuspb.ClientConfig_GenericXdsConfig{
makeGenericXdsConfig("type.googleapis.com/envoy.config.listener.v3.Listener", ldsTargets[1], "", v3adminpb.ClientResourceStatus_REQUESTED, nil, nil),
}
wantResp = &v3statuspb.ClientStatusResponse{
Config: []*v3statuspb.ClientConfig{
{
Node: wantNode,
GenericXdsConfigs: wantConfigs1,
},
},
}
if err := checkResourceDump(ctx, wantResp, client1); err != nil {
t.Fatal(err)
}
wantResp = &v3statuspb.ClientStatusResponse{
Config: []*v3statuspb.ClientConfig{
{
Node: wantNode,
GenericXdsConfigs: wantConfigs2,
},
},
}
if err := checkResourceDump(ctx, wantResp, client2); err != nil {
t.Fatal(err)
}
// Configure resources on the first management server.
if err := mgmtServer1.Update(ctx, e2e.UpdateOptions{
NodeID: nodeID,
Listeners: listeners[:1],
SkipValidation: true,
}); err != nil {
t.Fatal(err)
}
// Check the resource dump. One client should have resources in ACKED state,
// while the other should still have resources in REQUESTED state.
wantConfigs1 = []*v3statuspb.ClientConfig_GenericXdsConfig{
makeGenericXdsConfig("type.googleapis.com/envoy.config.listener.v3.Listener", ldsTargets[0], "1", v3adminpb.ClientResourceStatus_ACKED, listenerAnys[0], nil),
}
wantResp = &v3statuspb.ClientStatusResponse{
Config: []*v3statuspb.ClientConfig{
{
Node: wantNode,
GenericXdsConfigs: wantConfigs1,
},
},
}
if err := checkResourceDump(ctx, wantResp, client1); err != nil {
t.Fatal(err)
}
wantResp = &v3statuspb.ClientStatusResponse{
Config: []*v3statuspb.ClientConfig{
{
Node: wantNode,
GenericXdsConfigs: wantConfigs2,
},
},
}
if err := checkResourceDump(ctx, wantResp, client2); err != nil {
t.Fatal(err)
}
// Configure resources on the second management server.
if err := mgmtServer2.Update(ctx, e2e.UpdateOptions{
NodeID: nodeID,
Listeners: listeners[1:],
SkipValidation: true,
}); err != nil {
t.Fatal(err)
}
// Check the resource dump. Both clients should have appropriate resources
// in REQUESTED state.
wantConfigs2 = []*v3statuspb.ClientConfig_GenericXdsConfig{
makeGenericXdsConfig("type.googleapis.com/envoy.config.listener.v3.Listener", ldsTargets[1], "1", v3adminpb.ClientResourceStatus_ACKED, listenerAnys[1], nil),
}
wantResp = &v3statuspb.ClientStatusResponse{
Config: []*v3statuspb.ClientConfig{
{
Node: wantNode,
GenericXdsConfigs: wantConfigs1,
},
},
}
if err := checkResourceDump(ctx, wantResp, client1); err != nil {
t.Fatal(err)
}
wantResp = &v3statuspb.ClientStatusResponse{
Config: []*v3statuspb.ClientConfig{
{
Node: wantNode,
GenericXdsConfigs: wantConfigs2,
},
},
}
if err := checkResourceDump(ctx, wantResp, client2); err != nil {
t.Fatal(err)
}
}
// Tests that when a resource update is NACKed by the xDS client, the resource
// dump mentions that the xDS client is using the old good version of the
// resource, but contains the error state for the most recent bad update.
func (s) TestDumpResources_ACK_NACK(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
// Spin up an xDS management server on a local port.
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{})
nodeID := uuid.New().String()
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
client := createXDSClient(t, mgmtServer.Address, nodeID, grpctransport.NewBuilder(configs))
// Start watching a listener resource.
client.WatchResource(xdsresource.V3ListenerURL, ldsName, noopListenerWatcher{})
// Configure the management server with a good version of the listener resource.
goodListener := e2e.DefaultClientListener(ldsName, rdsName)
if err := mgmtServer.Update(ctx, e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{goodListener},
SkipValidation: true,
}); err != nil {
t.Fatal(err)
}
// Dump resources and expect ACK config with version "1".
wantNode := &v3corepb.Node{
Id: nodeID,
UserAgentName: "user-agent",
UserAgentVersionType: &v3corepb.Node_UserAgentVersion{UserAgentVersion: "0.0.0.0"},
ClientFeatures: []string{"envoy.lb.does_not_support_overprovisioning", "xds.config.resource-in-sotw"},
}
goodListenerAny := testutils.MarshalAny(t, goodListener)
wantConfigs := []*v3statuspb.ClientConfig_GenericXdsConfig{
makeGenericXdsConfig("type.googleapis.com/envoy.config.listener.v3.Listener", ldsName, "1", v3adminpb.ClientResourceStatus_ACKED, goodListenerAny, nil),
}
wantResp := &v3statuspb.ClientStatusResponse{
Config: []*v3statuspb.ClientConfig{
{
Node: wantNode,
GenericXdsConfigs: wantConfigs,
},
},
}
if err := checkResourceDump(ctx, wantResp, client); err != nil {
t.Fatal(err)
}
// Update the management server with a bad version of the listener resource
// which is expected to be NACKed by the xDS client.
badListener := badListenerResource(t, ldsName)
if err := mgmtServer.Update(ctx, e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{badListener},
SkipValidation: true,
}); err != nil {
t.Fatal(err)
}
// Dump resources and verify that the xDS client is using the old good
// version of the resource ("1"), but contains the error state for the most
// recent bad update ("2").
wantConfigs = []*v3statuspb.ClientConfig_GenericXdsConfig{
makeGenericXdsConfig("type.googleapis.com/envoy.config.listener.v3.Listener", ldsName, "1", v3adminpb.ClientResourceStatus_NACKED, goodListenerAny, &v3adminpb.UpdateFailureState{VersionInfo: "2"}),
}
wantResp = &v3statuspb.ClientStatusResponse{
Config: []*v3statuspb.ClientConfig{
{
Node: wantNode,
GenericXdsConfigs: wantConfigs,
},
},
}
if err := checkResourceDump(ctx, wantResp, client); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,390 @@
/*
*
* Copyright 2025 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package xdsclient_test
import (
"bytes"
"context"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"testing"
"time"
"google.golang.org/grpc/internal/grpctest"
"google.golang.org/grpc/internal/xds/clients"
"google.golang.org/grpc/internal/xds/clients/internal/buffer"
"google.golang.org/grpc/internal/xds/clients/internal/pretty"
"google.golang.org/grpc/internal/xds/clients/xdsclient"
"google.golang.org/grpc/internal/xds/clients/xdsclient/internal/xdsresource"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
v3httppb "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
"github.com/google/go-cmp/cmp"
)
type s struct {
grpctest.Tester
}
func Test(t *testing.T) {
grpctest.RunSubTests(t, s{})
}
const (
defaultTestWatchExpiryTimeout = 500 * time.Millisecond
defaultTestTimeout = 10 * time.Second
defaultTestShortTimeout = 10 * time.Millisecond // For events expected to *not* happen.
// ListenerResourceTypeName represents the transport agnostic name for the
// listener resource.
listenerResourceTypeName = "ListenerResource"
ldsName = "xdsclient-test-lds-resource"
rdsName = "xdsclient-test-rds-resource"
ldsNameNewStyle = "xdstp:///envoy.config.listener.v3.Listener/xdsclient-test-lds-resource"
rdsNameNewStyle = "xdstp:///envoy.config.route.v3.RouteConfiguration/xdsclient-test-rds-resource"
)
var (
// Singleton instantiation of the resource type implementation.
listenerType = xdsclient.ResourceType{
TypeURL: xdsresource.V3ListenerURL,
TypeName: listenerResourceTypeName,
AllResourcesRequiredInSotW: true,
Decoder: listenerDecoder{},
}
)
func unmarshalListenerResource(rProto *anypb.Any) (string, listenerUpdate, error) {
rProto, err := xdsresource.UnwrapResource(rProto)
if err != nil {
return "", listenerUpdate{}, fmt.Errorf("failed to unwrap resource: %v", err)
}
if !xdsresource.IsListenerResource(rProto.GetTypeUrl()) {
return "", listenerUpdate{}, fmt.Errorf("unexpected listener resource type: %q", rProto.GetTypeUrl())
}
lis := &v3listenerpb.Listener{}
if err := proto.Unmarshal(rProto.GetValue(), lis); err != nil {
return "", listenerUpdate{}, fmt.Errorf("failed to unmarshal resource: %v", err)
}
lu, err := processListener(lis)
if err != nil {
return lis.GetName(), listenerUpdate{}, err
}
lu.Raw = rProto.GetValue()
return lis.GetName(), *lu, nil
}
func processListener(lis *v3listenerpb.Listener) (*listenerUpdate, error) {
if lis.GetApiListener() != nil {
return processClientSideListener(lis)
}
return processServerSideListener(lis)
}
// processClientSideListener checks if the provided Listener proto meets
// the expected criteria. If so, it returns a non-empty routeConfigName.
func processClientSideListener(lis *v3listenerpb.Listener) (*listenerUpdate, error) {
update := &listenerUpdate{}
apiLisAny := lis.GetApiListener().GetApiListener()
if !xdsresource.IsHTTPConnManagerResource(apiLisAny.GetTypeUrl()) {
return nil, fmt.Errorf("unexpected http connection manager resource type: %q", apiLisAny.GetTypeUrl())
}
apiLis := &v3httppb.HttpConnectionManager{}
if err := proto.Unmarshal(apiLisAny.GetValue(), apiLis); err != nil {
return nil, fmt.Errorf("failed to unmarshal api_listener: %v", err)
}
switch apiLis.RouteSpecifier.(type) {
case *v3httppb.HttpConnectionManager_Rds:
if configsource := apiLis.GetRds().GetConfigSource(); configsource.GetAds() == nil && configsource.GetSelf() == nil {
return nil, fmt.Errorf("LDS's RDS configSource is not ADS or Self: %+v", lis)
}
name := apiLis.GetRds().GetRouteConfigName()
if name == "" {
return nil, fmt.Errorf("empty route_config_name: %+v", lis)
}
update.RouteConfigName = name
case *v3httppb.HttpConnectionManager_RouteConfig:
routeU := apiLis.GetRouteConfig()
if routeU == nil {
return nil, fmt.Errorf("empty inline RDS resp:: %+v", lis)
}
if routeU.Name == "" {
return nil, fmt.Errorf("empty route_config_name in inline RDS resp: %+v", lis)
}
update.RouteConfigName = routeU.Name
case nil:
return nil, fmt.Errorf("no RouteSpecifier: %+v", apiLis)
default:
return nil, fmt.Errorf("unsupported type %T for RouteSpecifier", apiLis.RouteSpecifier)
}
return update, nil
}
func processServerSideListener(lis *v3listenerpb.Listener) (*listenerUpdate, error) {
if n := len(lis.ListenerFilters); n != 0 {
return nil, fmt.Errorf("unsupported field 'listener_filters' contains %d entries", n)
}
if lis.GetUseOriginalDst().GetValue() {
return nil, errors.New("unsupported field 'use_original_dst' is present and set to true")
}
addr := lis.GetAddress()
if addr == nil {
return nil, fmt.Errorf("no address field in LDS response: %+v", lis)
}
sockAddr := addr.GetSocketAddress()
if sockAddr == nil {
return nil, fmt.Errorf("no socket_address field in LDS response: %+v", lis)
}
lu := &listenerUpdate{
InboundListenerCfg: &inboundListenerConfig{
Address: sockAddr.GetAddress(),
Port: strconv.Itoa(int(sockAddr.GetPortValue())),
},
}
return lu, nil
}
type listenerDecoder struct{}
// Decode deserializes and validates an xDS resource serialized inside the
// provided `Any` proto, as received from the xDS management server.
func (listenerDecoder) Decode(resource *xdsclient.AnyProto, _ xdsclient.DecodeOptions) (*xdsclient.DecodeResult, error) {
name, listener, err := unmarshalListenerResource(resource.ToAny())
switch {
case name == "":
// Name is unset only when protobuf deserialization fails.
return nil, err
case err != nil:
// Protobuf deserialization succeeded, but resource validation failed.
return &xdsclient.DecodeResult{Name: name, Resource: &listenerResourceData{Resource: listenerUpdate{}}}, err
}
return &xdsclient.DecodeResult{Name: name, Resource: &listenerResourceData{Resource: listener}}, nil
}
// listenerResourceData wraps the configuration of a Listener resource as
// received from the management server.
//
// Implements the ResourceData interface.
type listenerResourceData struct {
xdsclient.ResourceData
Resource listenerUpdate
}
// Equal returns true if other is equal to l.
func (l *listenerResourceData) Equal(other xdsclient.ResourceData) bool {
if l == nil && other == nil {
return true
}
if (l == nil) != (other == nil) {
return false
}
return bytes.Equal(l.Resource.Raw, other.Bytes())
}
// ToJSON returns a JSON string representation of the resource data.
func (l *listenerResourceData) ToJSON() string {
return pretty.ToJSON(l.Resource)
}
// Bytes returns the underlying raw protobuf form of the listener resource.
func (l *listenerResourceData) Bytes() []byte {
return l.Resource.Raw
}
// ListenerUpdate contains information received in an LDS response, which is of
// interest to the registered LDS watcher.
type listenerUpdate struct {
// RouteConfigName is the route configuration name corresponding to the
// target which is being watched through LDS.
RouteConfigName string
// InboundListenerCfg contains inbound listener configuration.
InboundListenerCfg *inboundListenerConfig
// Raw is the resource from the xds response.
Raw []byte
}
// InboundListenerConfig contains information about the inbound listener, i.e
// the server-side listener.
type inboundListenerConfig struct {
// Address is the local address on which the inbound listener is expected to
// accept incoming connections.
Address string
// Port is the local port on which the inbound listener is expected to
// accept incoming connections.
Port string
}
func makeAuthorityName(name string) string {
segs := strings.Split(name, "/")
return strings.Join(segs, "")
}
func makeNewStyleLDSName(authority string) string {
return fmt.Sprintf("xdstp://%s/envoy.config.listener.v3.Listener/xdsclient-test-lds-resource", authority)
}
// buildResourceName returns the resource name in the format of an xdstp://
// resource.
func buildResourceName(typeName, auth, id string, ctxParams map[string]string) string {
var typS string
switch typeName {
case listenerResourceTypeName:
typS = "envoy.config.listener.v3.Listener"
default:
// If the name doesn't match any of the standard resources fallback
// to the type name.
typS = typeName
}
return (&xdsresource.Name{
Scheme: "xdstp",
Authority: auth,
Type: typS,
ID: id,
ContextParams: ctxParams,
}).String()
}
// testMetricsReporter is a MetricsReporter to be used in tests. It sends
// recording events on channels and provides helpers to check if certain events
// have taken place.
type testMetricsReporter struct {
metricsCh *buffer.Unbounded[any]
mu sync.Mutex
asyncReporters map[clients.AsyncReporter]struct{}
}
// newTestMetricsReporter returns a new testMetricsReporter.
func newTestMetricsReporter() *testMetricsReporter {
return &testMetricsReporter{
metricsCh: buffer.NewUnbounded[any](),
asyncReporters: make(map[clients.AsyncReporter]struct{}),
}
}
// waitForMetric waits for a metric to be recorded and verifies that the
// recorded metrics data matches the expected metricsDataWant. Returns
// an error if failed to wait or received wrong data.
func (r *testMetricsReporter) waitForMetric(ctx context.Context, metricsDataWant any) error {
select {
case got := <-r.metricsCh.Get():
r.metricsCh.Load()
if diff := cmp.Diff(got, metricsDataWant); diff != "" {
return fmt.Errorf("received unexpected metrics value (-got, +want): %v", diff)
}
return nil
case <-ctx.Done():
return fmt.Errorf("timeout waiting for metric check: %v", ctx.Err())
}
}
// waitForSpecificMetric waits for a specific metric to be recorded, ignoring
// and discarding any other metrics received in the meantime. Returns an error
// if the context expires before the requested metric is received.
//
// This is necessary when multiple distinct metrics (e.g., ServerFailure,
// ResourceUpdateValid, and XDSClientConnected) might be emitted asynchronously
// and concurrently. Using waitForMetric would fail the test if an unrelated
// metric happens to be at the front of the channel.
func (r *testMetricsReporter) waitForSpecificMetric(ctx context.Context, metricsDataWant any) error {
for {
select {
case got := <-r.metricsCh.Get():
r.metricsCh.Load()
if diff := cmp.Diff(got, metricsDataWant); diff == "" {
return nil
}
case <-ctx.Done():
return fmt.Errorf("timeout waiting for specific metric: %v", ctx.Err())
}
}
}
// Receive waits for a metric to be recorded and returns it. Returns an error
// if the context expires before a metric is received.
func (r *testMetricsReporter) Receive(ctx context.Context) (any, error) {
select {
case got := <-r.metricsCh.Get():
r.metricsCh.Load()
return got, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// Drain clears all accumulated metrics from the channel.
func (r *testMetricsReporter) Drain() {
for {
select {
case <-r.metricsCh.Get():
r.metricsCh.Load()
default:
return
}
}
}
// ReportMetric sends the metrics data to the metricsCh channel.
func (r *testMetricsReporter) ReportMetric(m any) {
r.metricsCh.Put(m)
}
func (r *testMetricsReporter) RegisterAsyncReporter(reporter clients.AsyncReporter) func() {
r.mu.Lock()
defer r.mu.Unlock()
r.asyncReporters[reporter] = struct{}{}
return sync.OnceFunc(func() {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.asyncReporters, reporter)
})
}
// triggerAsyncMetrics invokes the Report method on all registered asynchronous
// metric reporters. This allows tests to explicitly compel the emission of
// async gauge metrics at specific precise points in time for verification.
func (r *testMetricsReporter) triggerAsyncMetrics() {
r.mu.Lock()
defer r.mu.Unlock()
for reporter := range r.asyncReporters {
reporter.Report(r)
}
}
func (r *testMetricsReporter) numAsyncReporters() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.asyncReporters)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,911 @@
/*
*
* Copyright 2025 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package xdsclient_test
import (
"context"
"errors"
"net"
"testing"
"github.com/google/uuid"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/internal/testutils"
"google.golang.org/grpc/internal/xds/clients"
"google.golang.org/grpc/internal/xds/clients/grpctransport"
"google.golang.org/grpc/internal/xds/clients/internal/testutils/e2e"
"google.golang.org/grpc/internal/xds/clients/xdsclient"
"google.golang.org/grpc/internal/xds/clients/xdsclient/internal/xdsresource"
"google.golang.org/grpc/internal/xds/clients/xdsclient/metrics"
v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
v3discoverypb "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3"
)
// TestResourceUpdateMetrics configures an xDS client, and a management server
// to send valid and invalid LDS updates, and verifies that the expected metrics
// for both good and bad updates are emitted.
func (s) TestResourceUpdateMetrics(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
tmr := newTestMetricsReporter()
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("net.Listen() failed: %v", err)
}
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{Listener: l})
const listenerResourceName = "test-listener-resource"
const routeConfigurationName = "test-route-configuration-resource"
nodeID := uuid.New().String()
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{e2e.DefaultClientListener(listenerResourceName, routeConfigurationName)},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server with resources: %v, err: %v", resources, err)
}
resourceTypes := map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType}
si := clients.ServerIdentifier{
ServerURI: mgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{ServerIdentifier: si}},
Node: clients.Node{ID: nodeID},
TransportBuilder: grpctransport.NewBuilder(configs),
ResourceTypes: resourceTypes,
// Xdstp resource names used in this test do not specify an
// authority. These will end up looking up an entry with the
// empty key in the authorities map. Having an entry with an
// empty key and empty configuration, results in these
// resources also using the top-level configuration.
Authorities: map[string]xdsclient.Authority{
"": {XDSServers: []xdsclient.ServerConfig{}},
},
MetricsReporter: tmr,
}
// Create an xDS client with the above config.
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client.Close()
// Watch the valid listener configured on the management server. This should
// cause a resource update valid metric to emit eventually.
client.WatchResource(listenerType.TypeURL, listenerResourceName, noopListenerWatcher{})
if err := tmr.waitForMetric(ctx, &metrics.ResourceUpdateValid{ServerURI: mgmtServer.Address, ResourceType: "ListenerResource"}); err != nil {
t.Fatal(err)
}
// Update management server with a bad update. This should cause a resource
// update invalid metric to emit eventually.
resources = e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{e2e.DefaultClientListener(listenerResourceName, routeConfigurationName)},
SkipValidation: true,
}
resources.Listeners[0].ApiListener = nil
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server with resources: %v, err: %v", resources, err)
}
if err := tmr.waitForMetric(ctx, &metrics.ResourceUpdateInvalid{ServerURI: mgmtServer.Address, ResourceType: "ListenerResource"}); err != nil {
t.Fatal(err)
}
// Resource update valid metric should have not emitted.
sCtx, sCancel := context.WithTimeout(ctx, defaultTestShortTimeout)
defer sCancel()
if err := tmr.waitForMetric(sCtx, &metrics.ResourceUpdateValid{ServerURI: mgmtServer.Address, ResourceType: "ListenerResource"}); err == nil {
t.Fatal("tmr.WaitForInt64Count(ctx, mdWant) succeeded when expected to timeout.")
}
}
// TestServerFailureMetrics_BeforeResponseRecv configures an xDS client, and a
// management server. It then register a watcher and stops the management
// server before sending a resource update, and verifies that the expected
// metric for server failure is emitted.
func (s) TestServerFailureMetrics_BeforeResponseRecv(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
tmr := newTestMetricsReporter()
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("net.Listen() failed: %v", err)
}
lis := testutils.NewRestartableListener(l)
streamOpened := make(chan struct{}, 1)
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{
Listener: lis,
OnStreamOpen: func(context.Context, int64, string) error {
select {
case streamOpened <- struct{}{}:
default:
}
return nil
},
})
nodeID := uuid.New().String()
resourceTypes := map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType}
si := clients.ServerIdentifier{
ServerURI: mgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{ServerIdentifier: si}},
Node: clients.Node{ID: nodeID},
TransportBuilder: grpctransport.NewBuilder(configs),
ResourceTypes: resourceTypes,
// Xdstp resource names used in this test do not specify an
// authority. These will end up looking up an entry with the
// empty key in the authorities map. Having an entry with an
// empty key and empty configuration, results in these
// resources also using the top-level configuration.
Authorities: map[string]xdsclient.Authority{
"": {XDSServers: []xdsclient.ServerConfig{}},
},
MetricsReporter: tmr,
}
// Create an xDS client with the above config.
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client.Close()
const listenerResourceName = "test-listener-resource"
// Watch for the listener on the above management server.
client.WatchResource(listenerType.TypeURL, listenerResourceName, noopListenerWatcher{})
// Verify that an ADS stream is opened and an LDS request with the above
// resource name is sent.
select {
case <-streamOpened:
case <-ctx.Done():
t.Fatal("Timeout when waiting for ADS stream to open")
}
// Close the listener and ensure that the ADS stream breaks. This should
// cause a server failure metric to emit eventually.
lis.Stop()
// Restart to prevent the attempt to create a new ADS stream after back off.
lis.Restart()
if err := tmr.waitForMetric(ctx, &metrics.ServerFailure{ServerURI: mgmtServer.Address}); err != nil {
t.Fatal(err)
}
}
// TestServerFailureMetrics_AfterResponseRecv configures an xDS client and a
// management server to send a valid LDS update, and verifies that the
// successful update metric is emitted. When the client ACKs the update, the
// server returns an error, breaking the stream. The test then verifies that the
// server failure metric is not emitted, because the ADS stream was closed after
// a response was received on the stream. Finally, the test waits for the client
// to establish a new stream and verifies that the client emits a metric after
// receiving a successful update.
func (s) TestServerFailureMetrics_AfterResponseRecv(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
tmr := newTestMetricsReporter()
l, err := testutils.LocalTCPListener()
if err != nil {
t.Fatalf("net.Listen() failed: %v", err)
}
lis := testutils.NewRestartableListener(l)
streamCreationQuota := make(chan struct{}, 1)
streamCreationQuota <- struct{}{}
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{
Listener: lis,
OnStreamOpen: func(context.Context, int64, string) error {
// The following select block is used to block stream creation after
// the first stream has failed, but while we are waiting to verify
// that the failure metric is not reported.
select {
case <-streamCreationQuota:
case <-ctx.Done():
}
return nil
},
OnStreamRequest: func(streamID int64, req *v3discoverypb.DiscoveryRequest) error {
// We only want the ACK on the first stream to return an error
// (leading to stream closure), without effecting subsequent stream
// attempts.
if streamID == 1 && req.GetVersionInfo() != "" {
return errors.New("test configured error")
}
return nil
}},
)
const listenerResourceName = "test-listener-resource"
const routeConfigurationName = "test-route-configuration-resource"
nodeID := uuid.New().String()
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{e2e.DefaultClientListener(listenerResourceName, routeConfigurationName)},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server with resources: %v, err: %v", resources, err)
}
resourceTypes := map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType}
si := clients.ServerIdentifier{
ServerURI: mgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{ServerIdentifier: si}},
Node: clients.Node{ID: nodeID},
TransportBuilder: grpctransport.NewBuilder(configs),
ResourceTypes: resourceTypes,
// Xdstp resource names used in this test do not specify an
// authority. These will end up looking up an entry with the
// empty key in the authorities map. Having an entry with an
// empty key and empty configuration, results in these
// resources also using the top-level configuration.
Authorities: map[string]xdsclient.Authority{
"": {XDSServers: []xdsclient.ServerConfig{}},
},
MetricsReporter: tmr,
}
// Create an xDS client with the above config.
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client.Close()
// Watch the valid listener configured on the management server. This should
// cause a resource update valid metric to emit eventually.
client.WatchResource(listenerType.TypeURL, listenerResourceName, noopListenerWatcher{})
if err := tmr.waitForMetric(ctx, &metrics.ResourceUpdateValid{ServerURI: mgmtServer.Address, ResourceType: "ListenerResource"}); err != nil {
t.Fatal(err)
}
// When the client sends an ACK, the management server would reply with an
// error, breaking the stream.
// Server failure should still have no recording point.
sCtx, sCancel := context.WithTimeout(ctx, defaultTestShortTimeout)
defer sCancel()
failureMetric := &metrics.ServerFailure{ServerURI: mgmtServer.Address}
if err := tmr.waitForMetric(sCtx, failureMetric); err == nil {
t.Fatalf("tmr.waitForMetric(%v) succeeded when expected to timeout.", failureMetric)
} else if sCtx.Err() == nil {
t.Fatalf("tmr.WaitForInt64Count(%v) = %v, want context deadline exceeded", failureMetric, err)
}
// Unblock stream creation and verify that an update is received
// successfully.
close(streamCreationQuota)
if err := tmr.waitForMetric(ctx, &metrics.ResourceUpdateValid{ServerURI: mgmtServer.Address, ResourceType: "ListenerResource"}); err != nil {
t.Fatal(err)
}
}
// TestConnectedMetric verifies the "grpc.xds_client.connected" metric state
// transitions. It begins by ensuring no metrics are reported before connection
// is attempted. Then it establishes a connection by watching a valid resource
// and verifies the connected state pulses to 1. Finally, it stops the
// management server and verifies the state drops back to 0.
func (s) TestConnectedMetric(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
tmr := newTestMetricsReporter()
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("net.Listen() failed: %v", err)
}
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{Listener: l})
nodeID := uuid.New().String()
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{
ServerIdentifier: clients.ServerIdentifier{
ServerURI: mgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
},
}},
Node: clients.Node{ID: nodeID},
TransportBuilder: grpctransport.NewBuilder(map[string]grpctransport.Config{
"insecure": {Credentials: insecure.NewBundle()},
}),
ResourceTypes: map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType},
Authorities: map[string]xdsclient.Authority{
"": {XDSServers: []xdsclient.ServerConfig{}},
},
MetricsReporter: tmr,
}
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client.Close()
tmr.triggerAsyncMetrics()
sCtx, sCancel := context.WithTimeout(ctx, defaultTestShortTimeout)
defer sCancel()
if err := tmr.waitForSpecificMetric(sCtx, &metrics.XDSClientConnected{ServerURI: mgmtServer.Address}); err == nil {
t.Fatal("XDSClientConnected metric reported before any watch was started")
}
const listenerName = "test-listener-resource"
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{e2e.DefaultClientListener(listenerName, "route-config")},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server: %v", err)
}
client.WatchResource(listenerType.TypeURL, listenerName, noopListenerWatcher{})
// Wait for the update to ensure we are connected.
if err := tmr.waitForMetric(ctx, &metrics.ResourceUpdateValid{ServerURI: mgmtServer.Address, ResourceType: "ListenerResource"}); err != nil {
t.Fatal(err)
}
// Now trigger async metrics.
tmr.triggerAsyncMetrics()
if err := tmr.waitForSpecificMetric(ctx, &metrics.XDSClientConnected{ServerURI: mgmtServer.Address, Value: 1}); err != nil {
t.Fatal(err)
}
mgmtServer.Stop()
// Wait for the synchronous server failure metric to confirm disconnect.
if err := tmr.waitForSpecificMetric(ctx, &metrics.ServerFailure{ServerURI: mgmtServer.Address}); err != nil {
t.Fatal(err)
}
tmr.triggerAsyncMetrics()
if err := tmr.waitForSpecificMetric(ctx, &metrics.XDSClientConnected{ServerURI: mgmtServer.Address, Value: 0}); err != nil {
t.Fatal(err)
}
// Verify async metric reporters are unregistered when client closes.
client.Close()
if count := tmr.numAsyncReporters(); count != 0 {
t.Fatalf("Async reporter not unregistered after client close, count: %d", count)
}
// Drain the channel of any leftover metrics from previous pulses.
tmr.Drain()
tmr.triggerAsyncMetrics()
// No metrics should be reported now because there are no reporters.
sCtx, sCancel = context.WithTimeout(ctx, defaultTestShortTimeout)
defer sCancel()
if _, err := tmr.Receive(sCtx); err == nil {
t.Fatal("Metrics reported after all reporters were unregistered")
}
}
// TestResourceMetrics verifies that the xDS client correctly tracks resource
// states (acked, nacked_but_cached) in "grpc.xds_client.resources" metric. It
// watches a resource, pushes a valid update from the management server, and
// asserts that the resource transitions to 'acked' state. Then it pushes an
// invalid update and asserts that the resource transitions to
// 'nacked_but_cached' state.
func (s) TestResourceMetrics(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
tmr := newTestMetricsReporter()
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("net.Listen() failed: %v", err)
}
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{Listener: l})
nodeID := uuid.New().String()
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{
ServerIdentifier: clients.ServerIdentifier{
ServerURI: mgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
},
}},
Node: clients.Node{ID: nodeID},
TransportBuilder: grpctransport.NewBuilder(map[string]grpctransport.Config{
"insecure": {Credentials: insecure.NewBundle()},
}),
ResourceTypes: map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType},
Authorities: map[string]xdsclient.Authority{
"": {XDSServers: []xdsclient.ServerConfig{}},
},
MetricsReporter: tmr,
}
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client.Close()
const listenerName = "test-listener-resource"
const routeConfigName = "test-route-configuration-resource"
client.WatchResource(listenerType.TypeURL, listenerName, noopListenerWatcher{})
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{e2e.DefaultClientListener(listenerName, routeConfigName)},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server: %v", err)
}
if err := tmr.waitForMetric(ctx, &metrics.ResourceUpdateValid{ServerURI: mgmtServer.Address, ResourceType: "ListenerResource"}); err != nil {
t.Fatal(err)
}
// Trigger async metrics.
tmr.triggerAsyncMetrics()
if err := tmr.waitForSpecificMetric(ctx, &metrics.XDSClientResourceStats{
Authority: "#old", // Default value for old-style non-xdstp as per gRFC A78
ResourceType: "ListenerResource",
CacheState: "acked",
Count: 1,
}); err != nil {
t.Fatal(err)
}
resources.Listeners[0].ApiListener = nil
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server: %v", err)
}
// Wait for Invalid update.
if err := tmr.waitForMetric(ctx, &metrics.ResourceUpdateInvalid{ServerURI: mgmtServer.Address, ResourceType: "ListenerResource"}); err != nil {
t.Fatal(err)
}
tmr.triggerAsyncMetrics()
if err := tmr.waitForSpecificMetric(ctx, &metrics.XDSClientResourceStats{
Authority: "#old",
ResourceType: "ListenerResource",
CacheState: "nacked_but_cached",
Count: 1,
}); err != nil {
t.Fatal(err)
}
}
// TestResourceMetrics_Extended verifies complex resource state transitions
// (requested, does_not_exist, nacked) in "grpc.xds_client.resources" metric
// across multiple resources. It watches several resources, pushes a partial
// update (some valid, some invalid) to transition them to 'acked' and 'nacked'
// states, then management server removes a resource and asserts that active
// watchers transition back to 'requested' while omitted resources transition
// to 'does_not_exist'.
func (s) TestResourceMetrics_Extended(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
tmr := newTestMetricsReporter()
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("net.Listen() failed: %v", err)
}
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{Listener: l})
nodeID := uuid.New().String()
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{
ServerIdentifier: clients.ServerIdentifier{
ServerURI: mgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
},
}},
Node: clients.Node{ID: nodeID},
TransportBuilder: grpctransport.NewBuilder(map[string]grpctransport.Config{
"insecure": {Credentials: insecure.NewBundle()},
}),
ResourceTypes: map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType},
Authorities: map[string]xdsclient.Authority{
"": {XDSServers: []xdsclient.ServerConfig{}},
},
MetricsReporter: tmr,
}
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client.Close()
resRequested1 := "res-requested-1"
resRequested2 := "res-requested-2"
resNacked1 := "res-nacked-1"
resNacked2 := "res-nacked-2"
resRemoved := "res-not-exist"
resList := []string{resRequested1, resRequested2, resNacked1, resNacked2, resRemoved}
for _, res := range resList {
client.WatchResource(listenerType.TypeURL, res, noopListenerWatcher{})
}
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{
e2e.DefaultClientListener(resNacked1, "route-config"),
e2e.DefaultClientListener(resNacked2, "route-config"),
e2e.DefaultClientListener(resRemoved, "route-config"),
},
SkipValidation: true,
}
resources.Listeners[0].ApiListener = nil
resources.Listeners[1].ApiListener = nil
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server: %v", err)
}
// Wait for the resource update to be accepted.
if err := tmr.waitForSpecificMetric(ctx, &metrics.ResourceUpdateValid{ServerURI: mgmtServer.Address, ResourceType: "ListenerResource"}); err != nil {
t.Fatal(err)
}
// Push an empty update to remove all resources from the management server.
resourcesEmpty := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resourcesEmpty); err != nil {
t.Fatalf("Failed to update management server: %v", err)
}
tmr.triggerAsyncMetrics()
if err := tmr.waitForSpecificMetric(ctx, &metrics.XDSClientResourceStats{
Authority: "#old",
ResourceType: "ListenerResource",
CacheState: "requested",
Count: 2,
}); err != nil {
t.Fatalf("Failed to verify requested count: %v", err)
}
tmr.triggerAsyncMetrics()
if err := tmr.waitForSpecificMetric(ctx, &metrics.XDSClientResourceStats{
Authority: "#old",
ResourceType: "ListenerResource",
CacheState: "nacked",
Count: 2,
}); err != nil {
t.Fatalf("Failed to verify nacked count: %v", err)
}
// Wait for the xDS client to process the empty update and trigger
// ResourceNotFound for omitted resources immediately.
lw := newListenerWatcher()
client.WatchResource(listenerType.TypeURL, resRemoved, lw)
// Verify that resources missing from the authoritative update transition to
// the does_not_exist state.
if err := verifyResourceErrorType(ctx, lw.resourceErrCh, xdsresource.ErrorTypeResourceNotFound, ""); err != nil {
t.Fatal(err)
}
tmr.triggerAsyncMetrics()
if err := tmr.waitForSpecificMetric(ctx, &metrics.XDSClientResourceStats{
Authority: "#old",
ResourceType: "ListenerResource",
CacheState: "does_not_exist",
Count: 1,
}); err != nil {
t.Fatalf("Failed to verify does_not_exist count: %v", err)
}
}
// TestConnectedMetric_Reconnection verifies "grpc.xds_client.connected" metric
// accuracy during flaky network conditions. It uses a restartable listener and
// a dial interceptor to pause connection attempts. It asserts that the
// connected state pulses to 1 when a stream succeeds, remains 1 immediately
// after failures before retries, transitions to 0 after subsequent failures,
// and returns to 1 only after successful stream recreation.
func (s) TestConnectedMetric_Reconnection(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
l, err := testutils.LocalTCPListener()
if err != nil {
t.Fatalf("net.Listen() failed: %v", err)
}
lis := testutils.NewRestartableListener(l)
sendResponse := make(chan struct{})
streamOpened := make(chan struct{}, 10)
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{
Listener: lis,
OnStreamOpen: func(ctx context.Context, _ int64, _ string) error {
select {
case streamOpened <- struct{}{}:
case <-ctx.Done():
}
return nil
},
OnStreamResponse: func(ctx context.Context, _ int64, _ *v3discoverypb.DiscoveryRequest, _ *v3discoverypb.DiscoveryResponse) {
select {
case <-sendResponse:
case <-ctx.Done():
}
},
})
nodeID := uuid.New().String()
resourceTypes := map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType}
si := clients.ServerIdentifier{
ServerURI: mgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
// streamAttemptCh is used by the interceptor to notify the test that a
// stream is attempting to open.
streamAttemptCh := testutils.NewChannel()
// commandCh is used by the test to tell the interceptor whether to block or pass.
commandCh := testutils.NewChannel()
// blocked is used by the interceptor to signal the test that it has
// successfully blocked the stream creation attempt.
blocked := make(chan struct{})
// unblock is used by the test to release the interceptor and allow the
// stream creation to proceed.
unblock := make(chan struct{})
// customGRPCNewClient overrides the gRPC client creation to inject a stream
// interceptor that can block ADS stream creation attempts for testing
// reconnection behavior.
customGRPCNewClient := func(target string, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
interceptor := func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, cops ...grpc.CallOption) (grpc.ClientStream, error) {
streamAttemptCh.Send(struct{}{}) // Notify test
cmd, err := commandCh.Receive(ctx) // Wait for command
if err != nil {
return nil, err
}
if cmd.(string) == "block" {
select {
case blocked <- struct{}{}:
case <-ctx.Done():
}
// Pause stream creation until released by the test.
<-unblock
}
return streamer(ctx, desc, cc, method, cops...)
}
opts = append(opts, grpc.WithStreamInterceptor(interceptor))
return grpc.NewClient(target, opts...)
}
tmr := newTestMetricsReporter()
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle(), GRPCNewClient: customGRPCNewClient}}
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{ServerIdentifier: si}},
Node: clients.Node{ID: nodeID},
TransportBuilder: grpctransport.NewBuilder(configs),
ResourceTypes: resourceTypes,
Authorities: map[string]xdsclient.Authority{
"": {XDSServers: []xdsclient.ServerConfig{}},
},
MetricsReporter: tmr,
}
waitForStreamSuccess := func() {
for {
select {
case <-streamOpened:
return
case <-streamAttemptCh.C:
commandCh.Send("pass")
case <-ctx.Done():
t.Fatalf("Timeout waiting for stream success")
}
}
}
// Stop listener to force initial connection failure.
lis.Stop()
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client.Close()
const listenerName = "test-listener-resource"
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{e2e.DefaultClientListener(listenerName, "route-config")},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server: %v", err)
}
// Initiate watch to trigger connection attempt.
client.WatchResource(listenerType.TypeURL, listenerName, noopListenerWatcher{})
tmr.triggerAsyncMetrics()
if err := tmr.waitForSpecificMetric(ctx, &metrics.XDSClientConnected{ServerURI: mgmtServer.Address, Value: 0}); err != nil {
t.Fatalf("XDSClientConnected check failed at start - got: %v, want 0", err)
}
// Verify state transitions to connected (Value: 1) when stream succeeds.
lis.Restart()
waitForStreamSuccess()
tmr.triggerAsyncMetrics()
if err := tmr.waitForSpecificMetric(ctx, &metrics.XDSClientConnected{ServerURI: mgmtServer.Address, Value: 1}); err != nil {
t.Fatalf("XDSClientConnected check failed after 1st NewStream - got: %v, want 1", err)
}
// Push resource update to confirm the connection is functional.
close(sendResponse)
if err := tmr.waitForSpecificMetric(ctx, &metrics.ResourceUpdateValid{ServerURI: mgmtServer.Address, ResourceType: "ListenerResource"}); err != nil {
t.Fatal(err)
}
// Verify state remains connected (Value: 1) immediately after failure before
// NewStream retry.
lis.Stop()
if _, err := streamAttemptCh.Receive(ctx); err != nil {
t.Fatalf("Timeout waiting for 2nd stream attempt (block): %v", err)
}
commandCh.Send("block")
<-blocked
tmr.triggerAsyncMetrics()
if err := tmr.waitForSpecificMetric(ctx, &metrics.XDSClientConnected{ServerURI: mgmtServer.Address, Value: 1}); err != nil {
t.Fatalf("XDSClientConnected check failed while NewStream is blocked - got: %v, want 1", err)
}
// Verify state transitions to disconnected (Value: 0) after NewStream attempt
// fails.
close(unblock)
if err := tmr.waitForSpecificMetric(ctx, &metrics.ServerFailure{ServerURI: mgmtServer.Address}); err != nil {
t.Fatal(err)
}
tmr.triggerAsyncMetrics()
if err := tmr.waitForSpecificMetric(ctx, &metrics.XDSClientConnected{ServerURI: mgmtServer.Address, Value: 0}); err != nil {
t.Fatalf("XDSClientConnected check failed after NewStream failure - got: %v, want 0", err)
}
sendResponse = make(chan struct{})
// Verify state remains disconnected (Value: 0) while waiting for the first
// response on the new stream.
lis.Restart()
// Clear channels to ensure fresh readings.
for len(streamOpened) > 0 {
<-streamOpened
}
waitForStreamSuccess()
tmr.Drain()
tmr.triggerAsyncMetrics()
if err := tmr.waitForSpecificMetric(ctx, &metrics.XDSClientConnected{ServerURI: mgmtServer.Address, Value: 0}); err != nil {
t.Fatalf("XDSClientConnected check failed after successful NewStream but before response - got: %v, want 0", err)
}
// Verify state returns to connected (Value: 1) after receiving the first
// response on the new stream.
close(sendResponse)
if err := tmr.waitForSpecificMetric(ctx, &metrics.ResourceUpdateValid{ServerURI: mgmtServer.Address, ResourceType: "ListenerResource"}); err != nil {
t.Fatal(err)
}
tmr.triggerAsyncMetrics()
if err := tmr.waitForSpecificMetric(ctx, &metrics.XDSClientConnected{ServerURI: mgmtServer.Address, Value: 1}); err != nil {
t.Fatalf("XDSClientConnected check failed after response- got: %v, want 1", err)
}
}
// TestResourceMetrics_AuthorityOldStyle verifies that the xDS client correctly
// falls back to '#old' in "grpc.xds_client.resources" metrics for legacy
// authorities. It watches a resource using default authority and asserts that
// the metric is reported under '#old' instead of an empty string.
func (s) TestResourceMetrics_AuthorityOldStyle(t *testing.T) {
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{})
nodeID := uuid.New().String()
serverCfg := xdsclient.ServerConfig{
ServerIdentifier: clients.ServerIdentifier{
ServerURI: mgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
},
}
tmr := newTestMetricsReporter()
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{serverCfg},
Node: clients.Node{ID: nodeID},
TransportBuilder: grpctransport.NewBuilder(map[string]grpctransport.Config{
"insecure": {Credentials: insecure.NewBundle()},
}),
ResourceTypes: map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType},
Authorities: map[string]xdsclient.Authority{
"": {XDSServers: []xdsclient.ServerConfig{serverCfg}},
},
MetricsReporter: tmr,
}
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
const listenerName = "test-listener"
client.WatchResource(listenerType.TypeURL, listenerName, newListenerWatcher())
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{e2e.DefaultClientListener(listenerName, "route-config")},
SkipValidation: true,
}
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server: %v", err)
}
if err := tmr.waitForMetric(ctx, &metrics.ResourceUpdateValid{ServerURI: mgmtServer.Address, ResourceType: "ListenerResource"}); err != nil {
t.Fatal(err)
}
// Verify that the client substitutes empty authority with '#old' in metrics.
tmr.triggerAsyncMetrics()
if err := tmr.waitForSpecificMetric(ctx, &metrics.XDSClientResourceStats{
Authority: "#old",
ResourceType: "ListenerResource",
CacheState: "acked",
Count: 1,
}); err != nil {
t.Fatalf("Failed to observe grpc.xds.authority '#old' metric substitution: %v", err)
}
}

View File

@@ -0,0 +1,529 @@
/*
*
* Copyright 2022 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package xdsclient_test
import (
"context"
"fmt"
"net"
"strings"
"testing"
"github.com/google/uuid"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/internal/xds/clients"
"google.golang.org/grpc/internal/xds/clients/grpctransport"
"google.golang.org/grpc/internal/xds/clients/internal/testutils"
"google.golang.org/grpc/internal/xds/clients/internal/testutils/e2e"
"google.golang.org/grpc/internal/xds/clients/internal/testutils/fakeserver"
"google.golang.org/grpc/internal/xds/clients/xdsclient"
"google.golang.org/grpc/internal/xds/clients/xdsclient/internal/xdsresource"
"google.golang.org/protobuf/types/known/anypb"
v3listenerpb "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3"
v3discoverypb "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3"
)
// testLDSWatcher is a test watcher that registers two watches corresponding to
// the names passed in at creation time on a valid update.
type testLDSWatcher struct {
client *xdsclient.XDSClient
name1, name2 string
lw1, lw2 *listenerWatcher
cancel1, cancel2 func()
updateCh *testutils.Channel
}
func newTestLDSWatcher(client *xdsclient.XDSClient, name1, name2 string) *testLDSWatcher {
return &testLDSWatcher{
client: client,
name1: name1,
name2: name2,
lw1: newListenerWatcher(),
lw2: newListenerWatcher(),
updateCh: testutils.NewChannelWithSize(1),
}
}
func (lw *testLDSWatcher) ResourceChanged(update xdsclient.ResourceData, onDone func()) {
lisData, ok := update.(*listenerResourceData)
if !ok {
lw.updateCh.Send(listenerUpdateErrTuple{resourceErr: fmt.Errorf("unexpected resource type: %T", update)})
onDone()
return
}
lw.updateCh.Send(listenerUpdateErrTuple{update: lisData.Resource})
lw.cancel1 = lw.client.WatchResource(xdsresource.V3ListenerURL, lw.name1, lw.lw1)
lw.cancel2 = lw.client.WatchResource(xdsresource.V3ListenerURL, lw.name2, lw.lw2)
onDone()
}
func (lw *testLDSWatcher) AmbientError(err error, onDone func()) {
// When used with a go-control-plane management server that continuously
// resends resources which are NACKed by the xDS client, using a `Replace()`
// here and in OnResourceDoesNotExist() simplifies tests which will have
// access to the most recently received error.
lw.updateCh.Replace(listenerUpdateErrTuple{ambientErr: err})
onDone()
}
func (lw *testLDSWatcher) ResourceError(_ error, onDone func()) {
lw.updateCh.Replace(listenerUpdateErrTuple{resourceErr: xdsresource.NewError(xdsresource.ErrorTypeResourceNotFound, "Listener not found in received response")})
onDone()
}
func (lw *testLDSWatcher) cancel() {
lw.cancel1()
lw.cancel2()
}
// TestWatchCallAnotherWatch tests the scenario where a watch is registered for
// a resource, and more watches are registered from the first watch's callback.
// The test verifies that this scenario does not lead to a deadlock.
func (s) TestWatchCallAnotherWatch(t *testing.T) {
// Start an xDS management server and set the option to allow it to respond
// to requests which only specify a subset of the configured resources.
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{AllowResourceSubset: true})
nodeID := uuid.New().String()
authority := makeAuthorityName(t.Name())
resourceTypes := map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType}
si := clients.ServerIdentifier{
ServerURI: mgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{ServerIdentifier: si}},
Node: clients.Node{ID: nodeID},
TransportBuilder: grpctransport.NewBuilder(configs),
ResourceTypes: resourceTypes,
// Xdstp style resource names used in this test use a slash removed
// version of t.Name as their authority, and the empty config
// results in the top-level xds server configuration being used for
// this authority.
Authorities: map[string]xdsclient.Authority{
authority: {XDSServers: []xdsclient.ServerConfig{}},
},
}
// Create an xDS client with the above config.
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client.Close()
// Configure the management server to return two listener resources,
// corresponding to the registered watches.
ldsNameNewStyle := makeNewStyleLDSName(authority)
resources := e2e.UpdateOptions{
NodeID: nodeID,
Listeners: []*v3listenerpb.Listener{
e2e.DefaultClientListener(ldsName, rdsName),
e2e.DefaultClientListener(ldsNameNewStyle, rdsNameNewStyle),
},
SkipValidation: true,
}
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
if err := mgmtServer.Update(ctx, resources); err != nil {
t.Fatalf("Failed to update management server with resources: %v, err: %v", resources, err)
}
// Create a listener watcher that registers two more watches from
// the OnUpdate callback:
// - one for the same resource name as this watch, which would be
// satisfied from xdsClient cache
// - the other for a different resource name, which would be
// satisfied from the server
lw := newTestLDSWatcher(client, ldsName, ldsNameNewStyle)
defer lw.cancel()
ldsCancel := client.WatchResource(xdsresource.V3ListenerURL, ldsName, lw)
defer ldsCancel()
// Verify the contents of the received update for the all watchers.
// Verify the contents of the received update for the all watchers. The two
// resources returned differ only in the resource name. Therefore the
// expected update is the same for all the watchers.
wantUpdate12 := listenerUpdateErrTuple{
update: listenerUpdate{
RouteConfigName: rdsName,
},
}
// Verify the contents of the received update for the all watchers. The two
// resources returned differ only in the resource name. Therefore the
// expected update is the same for all the watchers.
wantUpdate3 := listenerUpdateErrTuple{
update: listenerUpdate{
RouteConfigName: rdsNameNewStyle,
},
}
if err := verifyListenerUpdate(ctx, lw.updateCh, wantUpdate12); err != nil {
t.Fatal(err)
}
if err := verifyListenerUpdate(ctx, lw.lw1.updateCh, wantUpdate12); err != nil {
t.Fatal(err)
}
if err := verifyListenerUpdate(ctx, lw.lw2.updateCh, wantUpdate3); err != nil {
t.Fatal(err)
}
}
// TestNodeProtoSentOnlyInFirstRequest verifies that a non-empty node proto gets
// sent only on the first discovery request message on the ADS stream.
//
// It also verifies the same behavior holds after a stream restart.
func (s) TestNodeProtoSentOnlyInFirstRequest(t *testing.T) {
// Create a restartable listener which can close existing connections.
l, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("Error while listening. Err: %v", err)
}
lis := testutils.NewRestartableListener(l)
// Start a fake xDS management server with the above restartable listener.
//
// We are unable to use the go-control-plane server here, because it caches
// the node proto received in the first request message and adds it to
// subsequent requests before invoking the OnStreamRequest() callback.
// Therefore we cannot verify what is sent by the xDS client.
mgmtServer, cleanup, err := fakeserver.StartServer(lis)
if err != nil {
t.Fatalf("Failed to start fake xDS server: %v", err)
}
defer cleanup()
// Create bootstrap configuration pointing to the above management server.
nodeID := uuid.New().String()
resourceTypes := map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType}
si := clients.ServerIdentifier{
ServerURI: mgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{ServerIdentifier: si}},
Node: clients.Node{ID: nodeID},
TransportBuilder: grpctransport.NewBuilder(configs),
ResourceTypes: resourceTypes,
// Xdstp resource names used in this test do not specify an
// authority. These will end up looking up an entry with the
// empty key in the authorities map. Having an entry with an
// empty key and empty configuration, results in these
// resources also using the top-level configuration.
Authorities: map[string]xdsclient.Authority{
"": {XDSServers: []xdsclient.ServerConfig{}},
},
}
// Create an xDS client with the above config.
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client.Close()
const (
serviceName = "my-service-client-side-xds"
routeConfigName = "route-" + serviceName
clusterName = "cluster-" + serviceName
serviceName2 = "my-service-client-side-xds-2"
)
// Register a watch for the Listener resource.
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
watcher := newListenerWatcher()
ldsCancel1 := client.WatchResource(xdsresource.V3ListenerURL, serviceName, watcher)
defer ldsCancel1()
// Ensure the watch results in a discovery request with an empty node proto.
if err := readDiscoveryResponseAndCheckForNonEmptyNodeProto(ctx, mgmtServer.XDSRequestChan); err != nil {
t.Fatal(err)
}
// Configure a listener resource on the fake xDS server.
lisAny, err := anypb.New(e2e.DefaultClientListener(serviceName, routeConfigName))
if err != nil {
t.Fatalf("Failed to marshal listener resource into an Any proto: %v", err)
}
mgmtServer.XDSResponseChan <- &fakeserver.Response{
Resp: &v3discoverypb.DiscoveryResponse{
TypeUrl: "type.googleapis.com/envoy.config.listener.v3.Listener",
VersionInfo: "1",
Resources: []*anypb.Any{lisAny},
},
}
// The xDS client is expected to ACK the Listener resource. The discovery
// request corresponding to the ACK must contain a nil node proto.
if err := readDiscoveryResponseAndCheckForEmptyNodeProto(ctx, mgmtServer.XDSRequestChan); err != nil {
t.Fatal(err)
}
// Register a watch for another Listener resource.
ldscancel2 := client.WatchResource(xdsresource.V3ListenerURL, serviceName2, watcher)
defer ldscancel2()
// Ensure the watch results in a discovery request with an empty node proto.
if err := readDiscoveryResponseAndCheckForEmptyNodeProto(ctx, mgmtServer.XDSRequestChan); err != nil {
t.Fatal(err)
}
// Configure the other listener resource on the fake xDS server.
lisAny2, err := anypb.New(e2e.DefaultClientListener(serviceName2, routeConfigName))
if err != nil {
t.Fatalf("Failed to marshal route configuration resource into an Any proto: %v", err)
}
mgmtServer.XDSResponseChan <- &fakeserver.Response{
Resp: &v3discoverypb.DiscoveryResponse{
TypeUrl: "type.googleapis.com/envoy.config.listener.v3.Listener",
VersionInfo: "1",
Resources: []*anypb.Any{lisAny2},
},
}
// Ensure the discovery request for the ACK contains an empty node proto.
if err := readDiscoveryResponseAndCheckForEmptyNodeProto(ctx, mgmtServer.XDSRequestChan); err != nil {
t.Fatal(err)
}
// Stop the management server and expect the error callback to be invoked.
lis.Stop()
select {
case <-ctx.Done():
t.Fatal("Timeout when waiting for the connection error to be propagated to the watcher")
case <-watcher.ambientErrCh.C:
}
// Restart the management server.
lis.Restart()
// The xDS client is expected to re-request previously requested resources.
// Here, we expect 1 DiscoveryRequest messages with both the listener resources.
// The message should contain a non-nil node proto (since its the first
// request after restart).
//
// And since we don't push any responses on the response channel of the fake
// server, we do not expect any ACKs here.
if err := readDiscoveryResponseAndCheckForNonEmptyNodeProto(ctx, mgmtServer.XDSRequestChan); err != nil {
t.Fatal(err)
}
}
// readDiscoveryResponseAndCheckForEmptyNodeProto reads a discovery request
// message out of the provided reqCh. It returns an error if it fails to read a
// message before the context deadline expires, or if the read message contains
// a non-empty node proto.
func readDiscoveryResponseAndCheckForEmptyNodeProto(ctx context.Context, reqCh *testutils.Channel) error {
v, err := reqCh.Receive(ctx)
if err != nil {
return fmt.Errorf("Timeout when waiting for a DiscoveryRequest message")
}
req := v.(*fakeserver.Request).Req.(*v3discoverypb.DiscoveryRequest)
if node := req.GetNode(); node != nil {
return fmt.Errorf("Node proto received in DiscoveryRequest message is %v, want empty node proto", node)
}
return nil
}
// readDiscoveryResponseAndCheckForNonEmptyNodeProto reads a discovery request
// message out of the provided reqCh. It returns an error if it fails to read a
// message before the context deadline expires, or if the read message contains
// an empty node proto.
func readDiscoveryResponseAndCheckForNonEmptyNodeProto(ctx context.Context, reqCh *testutils.Channel) error {
v, err := reqCh.Receive(ctx)
if err != nil {
return fmt.Errorf("Timeout when waiting for a DiscoveryRequest message")
}
req := v.(*fakeserver.Request).Req.(*v3discoverypb.DiscoveryRequest)
if node := req.GetNode(); node == nil {
return fmt.Errorf("Empty node proto received in DiscoveryRequest message, want non-empty node proto")
}
return nil
}
// Tests that the errors returned by the xDS client when watching a resource
// contain the node ID that was used to create the client. This test covers two
// scenarios:
//
// 1. When a watch is registered for an already registered resource type, but
// a new watch is registered with a type url which is not present in
// provided resource types.
// 2. When a watch is registered for a resource name whose authority is not
// found in the xDS client config.
func (s) TestWatchErrorsContainNodeID(t *testing.T) {
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{})
// Create bootstrap configuration pointing to the above management server.
nodeID := uuid.New().String()
resourceTypes := map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType}
si := clients.ServerIdentifier{
ServerURI: mgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
configs := map[string]grpctransport.Config{"insecure": {Credentials: insecure.NewBundle()}}
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{ServerIdentifier: si}},
Node: clients.Node{ID: nodeID},
TransportBuilder: grpctransport.NewBuilder(configs),
ResourceTypes: resourceTypes,
// Xdstp resource names used in this test do not specify an
// authority. These will end up looking up an entry with the
// empty key in the authorities map. Having an entry with an
// empty key and empty configuration, results in these
// resources also using the top-level configuration.
Authorities: map[string]xdsclient.Authority{
"": {XDSServers: []xdsclient.ServerConfig{}},
},
}
// Create an xDS client with the above config.
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
t.Run("Right_Wrong_ResourceType_Implementations", func(t *testing.T) {
const listenerName = "listener-name"
watcher := newListenerWatcher()
client.WatchResource(xdsresource.V3ListenerURL, listenerName, watcher)
sCtx, sCancel := context.WithTimeout(ctx, defaultTestShortTimeout)
defer sCancel()
select {
case <-sCtx.Done():
case <-watcher.updateCh.C:
t.Fatal("Unexpected resource update")
case <-watcher.resourceErrCh.C:
t.Fatal("Unexpected resource error")
}
client.WatchResource("non-existent-type-url", listenerName, watcher)
select {
case <-ctx.Done():
t.Fatal("Timeout when waiting for error callback to be invoked")
case u, ok := <-watcher.resourceErrCh.C:
if !ok {
t.Fatalf("got no update, wanted listener resource error from the management server")
}
gotErr := u.(listenerUpdateErrTuple).resourceErr
if !strings.Contains(gotErr.Error(), nodeID) {
t.Fatalf("Unexpected error: %v, want error with node ID: %q", err, nodeID)
}
}
})
t.Run("Missing_Authority", func(t *testing.T) {
const listenerName = "xdstp://nonexistant-authority/envoy.config.listener.v3.Listener/listener-name"
watcher := newListenerWatcher()
client.WatchResource(xdsresource.V3ListenerURL, listenerName, watcher)
select {
case <-ctx.Done():
t.Fatal("Timeout when waiting for error callback to be invoked")
case u, ok := <-watcher.resourceErrCh.C:
if !ok {
t.Fatalf("got no update, wanted listener resource error from the management server")
}
gotErr := u.(listenerUpdateErrTuple).resourceErr
if !strings.Contains(gotErr.Error(), nodeID) {
t.Fatalf("Unexpected error: %v, want error with node ID: %q", err, nodeID)
}
}
})
}
// erroringTransportBuilder is a transport builder which always returns a nil
// transport along with an error.
type erroringTransportBuilder struct{}
func (*erroringTransportBuilder) Build(_ clients.ServerIdentifier) (clients.Transport, error) {
return nil, fmt.Errorf("failed to create transport")
}
// Tests that the errors returned by the xDS client when watching a resource
// contain the node ID when channel creation to the management server fails.
func (s) TestWatchErrorsContainNodeID_ChannelCreationFailure(t *testing.T) {
mgmtServer := e2e.StartManagementServer(t, e2e.ManagementServerOptions{})
// Create bootstrap configuration pointing to the above management server.
nodeID := uuid.New().String()
resourceTypes := map[string]xdsclient.ResourceType{xdsresource.V3ListenerURL: listenerType}
si := clients.ServerIdentifier{
ServerURI: mgmtServer.Address,
Extensions: grpctransport.ServerIdentifierExtension{ConfigName: "insecure"},
}
xdsClientConfig := xdsclient.Config{
Servers: []xdsclient.ServerConfig{{ServerIdentifier: si}},
Node: clients.Node{ID: nodeID},
TransportBuilder: &erroringTransportBuilder{},
ResourceTypes: resourceTypes,
// Xdstp resource names used in this test do not specify an
// authority. These will end up looking up an entry with the
// empty key in the authorities map. Having an entry with an
// empty key and empty configuration, results in these
// resources also using the top-level configuration.
Authorities: map[string]xdsclient.Authority{
"": {XDSServers: []xdsclient.ServerConfig{}},
},
}
// Create an xDS client with the above config.
client, err := xdsclient.New(xdsClientConfig)
if err != nil {
t.Fatalf("Failed to create xDS client: %v", err)
}
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
defer cancel()
const listenerName = "listener-name"
watcher := newListenerWatcher()
client.WatchResource(xdsresource.V3ListenerURL, listenerName, watcher)
select {
case <-ctx.Done():
t.Fatal("Timeout when waiting for error callback to be invoked")
case u, ok := <-watcher.resourceErrCh.C:
if !ok {
t.Fatalf("got no update, wanted listener resource error from the management server")
}
gotErr := u.(listenerUpdateErrTuple).resourceErr
if !strings.Contains(gotErr.Error(), nodeID) {
t.Fatalf("Unexpected error: %v, want error with node ID: %q", err, nodeID)
}
}
}