// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package discovery import ( "errors" "fmt" "testing" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/anypb" "github.com/hashicorp/consul/agent/config" mockpbresource "github.com/hashicorp/consul/grpcmocks/proto-public/pbresource" "github.com/hashicorp/consul/internal/resource" pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v2beta1" "github.com/hashicorp/consul/proto-public/pbresource" "github.com/hashicorp/consul/sdk/testutil" ) var ( unknownErr = errors.New("I don't feel so good") ) // Test_FetchService tests the FetchService method in scenarios where the RPC // call succeeds and fails. func Test_FetchWorkload(t *testing.T) { rc := &config.RuntimeConfig{ DNSOnlyPassing: false, } tests := []struct { name string queryPayload *QueryPayload context Context configureMockClient func(mockClient *mockpbresource.ResourceServiceClient_Expecter) expectedResult *Result expectedErr error }{ { name: "FetchWorkload returns result", queryPayload: &QueryPayload{ Name: "foo-1234", }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { result := getTestWorkloadResponse(t, "foo-1234", "", "") mockClient.Read(mock.Anything, mock.Anything). Return(result, nil). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, result.GetResource().GetId().GetName(), req.Id.Name) }) }, expectedResult: &Result{ Node: &Location{Name: "foo-1234", Address: "1.2.3.4"}, Type: ResultTypeWorkload, Ports: []Port{ { Name: "api", Number: 5678, }, { Name: "mesh", Number: 21000, }, }, Tenancy: ResultTenancy{ Namespace: resource.DefaultNamespaceName, Partition: resource.DefaultPartitionName, }, }, expectedErr: nil, }, { name: "FetchWorkload for non-existent workload", queryPayload: &QueryPayload{ Name: "foo-1234", }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { input := getTestWorkloadResponse(t, "foo-1234", "", "") mockClient.Read(mock.Anything, mock.Anything). Return(nil, status.Error(codes.NotFound, "not found")). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, input.GetResource().GetId().GetName(), req.Id.Name) }) }, expectedResult: nil, expectedErr: ErrNotFound, }, { name: "FetchWorkload encounters a resource client error", queryPayload: &QueryPayload{ Name: "foo-1234", }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { input := getTestWorkloadResponse(t, "foo-1234", "", "") mockClient.Read(mock.Anything, mock.Anything). Return(nil, unknownErr). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, input.GetResource().GetId().GetName(), req.Id.Name) }) }, expectedResult: nil, expectedErr: unknownErr, }, { name: "FetchWorkload with a matching port", queryPayload: &QueryPayload{ Name: "foo-1234", PortName: "api", }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { result := getTestWorkloadResponse(t, "foo-1234", "", "") mockClient.Read(mock.Anything, mock.Anything). Return(result, nil). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, result.GetResource().GetId().GetName(), req.Id.Name) }) }, expectedResult: &Result{ Node: &Location{Name: "foo-1234", Address: "1.2.3.4"}, Type: ResultTypeWorkload, Ports: []Port{ { Name: "api", Number: 5678, }, }, Tenancy: ResultTenancy{ Namespace: resource.DefaultNamespaceName, Partition: resource.DefaultPartitionName, }, }, expectedErr: nil, }, { name: "FetchWorkload with a matching port", queryPayload: &QueryPayload{ Name: "foo-1234", PortName: "not-api", }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { result := getTestWorkloadResponse(t, "foo-1234", "", "") mockClient.Read(mock.Anything, mock.Anything). Return(result, nil). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, result.GetResource().GetId().GetName(), req.Id.Name) }) }, expectedResult: nil, expectedErr: ErrNotFound, }, { name: "FetchWorkload returns result for non-default tenancy", queryPayload: &QueryPayload{ Name: "foo-1234", Tenancy: QueryTenancy{ Namespace: "test-namespace", Partition: "test-partition", }, }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { result := getTestWorkloadResponse(t, "foo-1234", "test-namespace", "test-partition") mockClient.Read(mock.Anything, mock.Anything). Return(result, nil). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, result.GetResource().GetId().GetName(), req.Id.Name) require.Equal(t, result.GetResource().GetId().GetTenancy().GetNamespace(), req.Id.Tenancy.Namespace) require.Equal(t, result.GetResource().GetId().GetTenancy().GetPartition(), req.Id.Tenancy.Partition) }) }, expectedResult: &Result{ Node: &Location{Name: "foo-1234", Address: "1.2.3.4"}, Type: ResultTypeWorkload, Ports: []Port{ { Name: "api", Number: 5678, }, { Name: "mesh", Number: 21000, }, }, Tenancy: ResultTenancy{ Namespace: "test-namespace", Partition: "test-partition", }, }, expectedErr: nil, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { logger := testutil.Logger(t) client := mockpbresource.NewResourceServiceClient(t) mockClient := client.EXPECT() tc.configureMockClient(mockClient) df := NewV2DataFetcher(rc, client, logger) result, err := df.FetchWorkload(tc.context, tc.queryPayload) require.True(t, errors.Is(err, tc.expectedErr)) require.Equal(t, tc.expectedResult, result) }) } } // Test_V2FetchEndpoints the FetchService method in scenarios where the RPC // call succeeds and fails. func Test_V2FetchEndpoints(t *testing.T) { tests := []struct { name string queryPayload *QueryPayload context Context configureMockClient func(mockClient *mockpbresource.ResourceServiceClient_Expecter) rc *config.RuntimeConfig expectedResult []*Result expectedErr error verifyShuffle bool }{ { name: "FetchEndpoints returns result", queryPayload: &QueryPayload{ Name: "consul", }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { endpoints := []*pbcatalog.Endpoint{ makeEndpoint("consul-1", "1.2.3.4", pbcatalog.Health_HEALTH_PASSING, 0, 0), } serviceEndpoints := getTestEndpointsResponse(t, "", "", endpoints...) mockClient.Read(mock.Anything, mock.Anything). Return(serviceEndpoints, nil). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, serviceEndpoints.GetResource().GetId().GetName(), req.Id.Name) }) }, expectedResult: []*Result{ { Node: &Location{Name: "consul-1", Address: "1.2.3.4"}, Type: ResultTypeWorkload, Ports: []Port{ { Name: "api", Number: 5678, }, { Name: "mesh", Number: 21000, }, }, Tenancy: ResultTenancy{ Namespace: resource.DefaultNamespaceName, Partition: resource.DefaultPartitionName, }, DNS: DNSConfig{ Weight: 1, }, }, }, }, { name: "FetchEndpoints returns empty result with no endpoints", queryPayload: &QueryPayload{ Name: "consul", }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { result := getTestEndpointsResponse(t, "", "") mockClient.Read(mock.Anything, mock.Anything). Return(result, nil). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, result.GetResource().GetId().GetName(), req.Id.Name) }) }, expectedResult: []*Result{}, }, { name: "FetchEndpoints returns a name error when the ServiceEndpoint does not exist", queryPayload: &QueryPayload{ Name: "consul", }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { result := getTestEndpointsResponse(t, "", "") mockClient.Read(mock.Anything, mock.Anything). Return(nil, status.Error(codes.NotFound, "not found")). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, result.GetResource().GetId().GetName(), req.Id.Name) }) }, expectedErr: ErrNotFound, }, { name: "FetchEndpoints encounters a resource client error", queryPayload: &QueryPayload{ Name: "consul", }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { result := getTestEndpointsResponse(t, "", "") mockClient.Read(mock.Anything, mock.Anything). Return(nil, unknownErr). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, result.GetResource().GetId().GetName(), req.Id.Name) }) }, expectedErr: unknownErr, }, { name: "FetchEndpoints always filters out critical endpoints; DNS weights applied correctly", queryPayload: &QueryPayload{ Name: "consul", }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { results := []*pbcatalog.Endpoint{ makeEndpoint("consul-1", "1.2.3.4", pbcatalog.Health_HEALTH_PASSING, 2, 3), makeEndpoint("consul-2", "2.3.4.5", pbcatalog.Health_HEALTH_WARNING, 2, 3), makeEndpoint("consul-3", "3.4.5.6", pbcatalog.Health_HEALTH_CRITICAL, 2, 3), } result := getTestEndpointsResponse(t, "", "", results...) mockClient.Read(mock.Anything, mock.Anything). Return(result, nil). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, result.GetResource().GetId().GetName(), req.Id.Name) }) }, expectedResult: []*Result{ { Node: &Location{Name: "consul-1", Address: "1.2.3.4"}, Type: ResultTypeWorkload, Tenancy: ResultTenancy{ Namespace: resource.DefaultNamespaceName, Partition: resource.DefaultPartitionName, }, DNS: DNSConfig{ Weight: 2, }, Ports: []Port{ { Name: "api", Number: 5678, }, { Name: "mesh", Number: 21000, }, }, }, { Node: &Location{Name: "consul-2", Address: "2.3.4.5"}, Type: ResultTypeWorkload, Tenancy: ResultTenancy{ Namespace: resource.DefaultNamespaceName, Partition: resource.DefaultPartitionName, }, DNS: DNSConfig{ Weight: 3, }, Ports: []Port{ { Name: "api", Number: 5678, }, { Name: "mesh", Number: 21000, }, }, }, }, }, { name: "FetchEndpoints filters out warning endpoints when DNSOnlyPassing is true", queryPayload: &QueryPayload{ Name: "consul", }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { results := []*pbcatalog.Endpoint{ makeEndpoint("consul-1", "1.2.3.4", pbcatalog.Health_HEALTH_PASSING, 2, 3), makeEndpoint("consul-2", "2.3.4.5", pbcatalog.Health_HEALTH_WARNING, 2, 3), makeEndpoint("consul-3", "3.4.5.6", pbcatalog.Health_HEALTH_CRITICAL, 2, 3), } result := getTestEndpointsResponse(t, "", "", results...) mockClient.Read(mock.Anything, mock.Anything). Return(result, nil). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, result.GetResource().GetId().GetName(), req.Id.Name) }) }, rc: &config.RuntimeConfig{ DNSOnlyPassing: true, }, expectedResult: []*Result{ { Node: &Location{Name: "consul-1", Address: "1.2.3.4"}, Type: ResultTypeWorkload, Tenancy: ResultTenancy{ Namespace: resource.DefaultNamespaceName, Partition: resource.DefaultPartitionName, }, DNS: DNSConfig{ Weight: 2, }, Ports: []Port{ { Name: "api", Number: 5678, }, { Name: "mesh", Number: 21000, }, }, }, }, }, { name: "FetchEndpoints shuffles the results", queryPayload: &QueryPayload{ Name: "consul", }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { results := []*pbcatalog.Endpoint{ // use a set of 10 elements, the odds of getting the same result are 1 in 3628800 makeEndpoint("consul-1", "10.0.0.1", pbcatalog.Health_HEALTH_PASSING, 0, 0), makeEndpoint("consul-2", "10.0.0.2", pbcatalog.Health_HEALTH_PASSING, 0, 0), makeEndpoint("consul-3", "10.0.0.3", pbcatalog.Health_HEALTH_PASSING, 0, 0), makeEndpoint("consul-4", "10.0.0.4", pbcatalog.Health_HEALTH_PASSING, 0, 0), makeEndpoint("consul-5", "10.0.0.5", pbcatalog.Health_HEALTH_PASSING, 0, 0), makeEndpoint("consul-6", "10.0.0.6", pbcatalog.Health_HEALTH_PASSING, 0, 0), makeEndpoint("consul-7", "10.0.0.7", pbcatalog.Health_HEALTH_PASSING, 0, 0), makeEndpoint("consul-8", "10.0.0.8", pbcatalog.Health_HEALTH_PASSING, 0, 0), makeEndpoint("consul-9", "10.0.0.9", pbcatalog.Health_HEALTH_PASSING, 0, 0), makeEndpoint("consul-10", "10.0.0.10", pbcatalog.Health_HEALTH_PASSING, 0, 0), } result := getTestEndpointsResponse(t, "", "", results...) mockClient.Read(mock.Anything, mock.Anything). Return(result, nil). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, result.GetResource().GetId().GetName(), req.Id.Name) }) }, expectedResult: func() []*Result { results := make([]*Result, 0, 10) for i := 0; i < 10; i++ { name := fmt.Sprintf("consul-%d", i+1) address := fmt.Sprintf("10.0.0.%d", i+1) result := &Result{ Node: &Location{Name: name, Address: address}, Type: ResultTypeWorkload, Tenancy: ResultTenancy{ Namespace: resource.DefaultNamespaceName, Partition: resource.DefaultPartitionName, }, Ports: []Port{ { Name: "api", Number: 5678, }, { Name: "mesh", Number: 21000, }, }, DNS: DNSConfig{ Weight: 1, }, } results = append(results, result) } return results }(), verifyShuffle: true, }, { name: "FetchEndpoints returns only the specified limit", queryPayload: &QueryPayload{ Name: "consul", Limit: 1, }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { results := []*pbcatalog.Endpoint{ // intentionally all the same to make this easier to verify makeEndpoint("consul-1", "10.0.0.1", pbcatalog.Health_HEALTH_PASSING, 0, 0), makeEndpoint("consul-1", "10.0.0.1", pbcatalog.Health_HEALTH_PASSING, 0, 0), makeEndpoint("consul-1", "10.0.0.1", pbcatalog.Health_HEALTH_PASSING, 0, 0), } result := getTestEndpointsResponse(t, "", "", results...) mockClient.Read(mock.Anything, mock.Anything). Return(result, nil). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, result.GetResource().GetId().GetName(), req.Id.Name) }) }, expectedResult: []*Result{ { Node: &Location{Name: "consul-1", Address: "10.0.0.1"}, Type: ResultTypeWorkload, Tenancy: ResultTenancy{ Namespace: resource.DefaultNamespaceName, Partition: resource.DefaultPartitionName, }, DNS: DNSConfig{ Weight: 1, }, Ports: []Port{ { Name: "api", Number: 5678, }, { Name: "mesh", Number: 21000, }, }, }, }, }, { name: "FetchEndpoints returns results with non-default tenancy", queryPayload: &QueryPayload{ Name: "consul", Tenancy: QueryTenancy{ Namespace: "test-namespace", Partition: "test-partition", }, }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { results := []*pbcatalog.Endpoint{ // intentionally all the same to make this easier to verify makeEndpoint("consul-1", "10.0.0.1", pbcatalog.Health_HEALTH_PASSING, 0, 0), } result := getTestEndpointsResponse(t, "test-namespace", "test-partition", results...) mockClient.Read(mock.Anything, mock.Anything). Return(result, nil). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, result.GetResource().GetId().GetName(), req.Id.Name) require.Equal(t, result.GetResource().GetId().GetTenancy().GetNamespace(), req.Id.Tenancy.Namespace) require.Equal(t, result.GetResource().GetId().GetTenancy().GetPartition(), req.Id.Tenancy.Partition) }) }, expectedResult: []*Result{ { Node: &Location{Name: "consul-1", Address: "10.0.0.1"}, Type: ResultTypeWorkload, Tenancy: ResultTenancy{ Namespace: "test-namespace", Partition: "test-partition", }, DNS: DNSConfig{ Weight: 1, }, Ports: []Port{ { Name: "api", Number: 5678, }, { Name: "mesh", Number: 21000, }, }, }, }, }, { name: "FetchEndpoints returns only a specific port if is one requested", queryPayload: &QueryPayload{ Name: "consul", PortName: "api", }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { endpoints := []*pbcatalog.Endpoint{ makeEndpoint("consul-1", "10.0.0.1", pbcatalog.Health_HEALTH_PASSING, 0, 0), } serviceEndpoints := getTestEndpointsResponse(t, "", "", endpoints...) mockClient.Read(mock.Anything, mock.Anything). Return(serviceEndpoints, nil). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, serviceEndpoints.GetResource().GetId().GetName(), req.Id.Name) }) }, expectedResult: []*Result{ { Node: &Location{Name: "consul-1", Address: "10.0.0.1"}, Type: ResultTypeWorkload, Ports: []Port{ { Name: "api", Number: 5678, }, // No mesh port this time }, Tenancy: ResultTenancy{ Namespace: resource.DefaultNamespaceName, Partition: resource.DefaultPartitionName, }, DNS: DNSConfig{ Weight: 1, }, }, }, }, { name: "FetchEndpoints returns a name error when a service doesn't implement the requested port", queryPayload: &QueryPayload{ Name: "consul", PortName: "banana", }, context: Context{ Token: "test-token", }, configureMockClient: func(mockClient *mockpbresource.ResourceServiceClient_Expecter) { endpoints := []*pbcatalog.Endpoint{ makeEndpoint("consul-1", "10.0.0.1", pbcatalog.Health_HEALTH_PASSING, 0, 0), } serviceEndpoints := getTestEndpointsResponse(t, "", "", endpoints...) mockClient.Read(mock.Anything, mock.Anything). Return(serviceEndpoints, nil). Once(). Run(func(args mock.Arguments) { req := args.Get(1).(*pbresource.ReadRequest) require.Equal(t, serviceEndpoints.GetResource().GetId().GetName(), req.Id.Name) }) }, expectedErr: ErrNotFound, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { logger := testutil.Logger(t) client := mockpbresource.NewResourceServiceClient(t) mockClient := client.EXPECT() tc.configureMockClient(mockClient) if tc.rc == nil { tc.rc = &config.RuntimeConfig{ DNSOnlyPassing: false, } } df := NewV2DataFetcher(tc.rc, client, logger) result, err := df.FetchEndpoints(tc.context, tc.queryPayload, LookupTypeService) require.True(t, errors.Is(err, tc.expectedErr)) if tc.verifyShuffle { require.NotEqualf(t, tc.expectedResult, result, "expected result to be shuffled. There is a small probability that it shuffled back to the original order. In that case, you may want to play the lottery.") } require.ElementsMatchf(t, tc.expectedResult, result, "elements of results should match") }) } } func getTestWorkloadResponse(t *testing.T, name string, nsOverride string, partitionOverride string) *pbresource.ReadResponse { workload := &pbcatalog.Workload{ Addresses: []*pbcatalog.WorkloadAddress{ { Host: "1.2.3.4", Ports: []string{"api", "mesh"}, }, }, Ports: map[string]*pbcatalog.WorkloadPort{ "api": { Port: 5678, }, "mesh": { Port: 21000, }, }, Identity: "test-identity", } data, err := anypb.New(workload) require.NoError(t, err) resp := &pbresource.ReadResponse{ Resource: &pbresource.Resource{ Id: &pbresource.ID{ Name: name, Type: pbcatalog.WorkloadType, Tenancy: resource.DefaultNamespacedTenancy(), }, Data: data, }, } if nsOverride != "" { resp.Resource.Id.Tenancy.Namespace = nsOverride } if partitionOverride != "" { resp.Resource.Id.Tenancy.Partition = partitionOverride } return resp } func makeEndpoint(name string, address string, health pbcatalog.Health, weightPassing, weightWarning uint32) *pbcatalog.Endpoint { endpoint := &pbcatalog.Endpoint{ Addresses: []*pbcatalog.WorkloadAddress{ { Host: address, Ports: []string{"api"}, }, }, Ports: map[string]*pbcatalog.WorkloadPort{ "api": { Port: 5678, }, "mesh": { Port: 21000, }, }, HealthStatus: health, TargetRef: &pbresource.ID{ Name: name, }, } if weightPassing > 0 || weightWarning > 0 { endpoint.Dns = &pbcatalog.DNSPolicy{ Weights: &pbcatalog.Weights{ Passing: weightPassing, Warning: weightWarning, }, } } return endpoint } func getTestEndpointsResponse(t *testing.T, nsOverride string, partitionOverride string, endpoints ...*pbcatalog.Endpoint) *pbresource.ReadResponse { serviceEndpoints := &pbcatalog.ServiceEndpoints{ Endpoints: endpoints, } data, err := anypb.New(serviceEndpoints) require.NoError(t, err) resp := &pbresource.ReadResponse{ Resource: &pbresource.Resource{ Id: &pbresource.ID{ Name: "consul", Type: pbcatalog.ServiceType, Tenancy: resource.DefaultNamespacedTenancy(), }, Data: data, }, } if nsOverride != "" { resp.Resource.Id.Tenancy.Namespace = nsOverride } if partitionOverride != "" { resp.Resource.Id.Tenancy.Partition = partitionOverride } return resp }