// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 package dataplane import ( "context" "testing" "github.com/hashicorp/go-hclog" "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/structpb" svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing" "github.com/hashicorp/consul/internal/catalog" "github.com/hashicorp/consul/internal/mesh" "github.com/hashicorp/consul/internal/resource" "github.com/hashicorp/consul/internal/resource/resourcetest" pbcatalog "github.com/hashicorp/consul/proto-public/pbcatalog/v2beta1" pbmesh "github.com/hashicorp/consul/proto-public/pbmesh/v2beta1" "github.com/hashicorp/consul/proto-public/pbresource" "github.com/hashicorp/consul/proto/private/prototest" "github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/acl/resolver" external "github.com/hashicorp/consul/agent/grpc-external" "github.com/hashicorp/consul/agent/grpc-external/testutils" "github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/proto-public/pbdataplane" ) const ( testIdentity = "test-identity" testToken = "acl-token-get-envoy-bootstrap-params" testServiceName = "web" proxyServiceID = "web-proxy" nodeName = "foo" nodeID = "2980b72b-bd9d-9d7b-d4f9-951bf7508d95" proxyConfigKey = "envoy_dogstatsd_url" proxyConfigValue = "udp://127.0.0.1:8125" serverDC = "dc1" protocolKey = "protocol" connectTimeoutKey = "local_connect_timeout_ms" requestTimeoutKey = "local_request_timeout_ms" proxyDefaultsProtocol = "http" proxyDefaultsRequestTimeout = 1111 serviceDefaultsProtocol = "tcp" serviceDefaultsConnectTimeout = 4444 testAccessLogs = "{\"name\":\"Consul Listener Filter Log\",\"typedConfig\":{\"@type\":\"type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog\",\"logFormat\":{\"jsonFormat\":{\"custom_field\":\"%START_TIME%\"}}}}" ) func testRegisterRequestProxy(t *testing.T) *structs.RegisterRequest { return &structs.RegisterRequest{ Datacenter: serverDC, Node: nodeName, ID: nodeID, Address: "127.0.0.1", Service: &structs.NodeService{ Kind: structs.ServiceKindConnectProxy, Service: proxyServiceID, ID: proxyServiceID, Address: "127.0.0.2", Port: 2222, Proxy: structs.ConnectProxyConfig{ DestinationServiceName: testServiceName, Config: map[string]interface{}{ proxyConfigKey: proxyConfigValue, }, }, EnterpriseMeta: *structs.DefaultEnterpriseMetaInDefaultPartition(), }, } } func testRegisterIngressGateway(t *testing.T) *structs.RegisterRequest { registerReq := structs.TestRegisterIngressGateway(t) registerReq.ID = "2980b72b-bd9d-9d7b-d4f9-951bf7508d95" registerReq.Service.ID = registerReq.Service.Service registerReq.Service.Proxy.Config = map[string]interface{}{ proxyConfigKey: proxyConfigValue, } return registerReq } func testProxyDefaults(t *testing.T, accesslogs bool) structs.ConfigEntry { pd := &structs.ProxyConfigEntry{ Kind: structs.ProxyDefaults, Name: structs.ProxyConfigGlobal, Config: map[string]interface{}{ protocolKey: proxyDefaultsProtocol, requestTimeoutKey: proxyDefaultsRequestTimeout, }, } if accesslogs { pd.AccessLogs.Enabled = true pd.AccessLogs.JSONFormat = "{ \"custom_field\": \"%START_TIME%\" }" } return pd } func testServiceDefaults(t *testing.T) structs.ConfigEntry { return &structs.ServiceConfigEntry{ Kind: structs.ServiceDefaults, Name: testServiceName, Protocol: serviceDefaultsProtocol, LocalConnectTimeoutMs: serviceDefaultsConnectTimeout, } } func requireConfigField(t *testing.T, resp *pbdataplane.GetEnvoyBootstrapParamsResponse, key string, value interface{}) { require.Contains(t, resp.Config.Fields, key) require.Equal(t, value, resp.Config.Fields[key]) } func TestGetEnvoyBootstrapParams_Success(t *testing.T) { type testCase struct { name string registerReq *structs.RegisterRequest nodeID bool proxyDefaults structs.ConfigEntry serviceDefaults structs.ConfigEntry } run := func(t *testing.T, tc testCase) { store := testutils.TestStateStore(t, nil) idx := uint64(1) err := store.EnsureRegistration(idx, tc.registerReq) require.NoError(t, err) if tc.proxyDefaults != nil { idx++ err := store.EnsureConfigEntry(idx, tc.proxyDefaults) require.NoError(t, err) } if tc.serviceDefaults != nil { idx++ err := store.EnsureConfigEntry(idx, tc.serviceDefaults) require.NoError(t, err) } aclResolver := &MockACLResolver{} aclResolver.On("ResolveTokenAndDefaultMeta", testToken, mock.Anything, mock.Anything). Return(testutils.ACLServiceRead(t, tc.registerReq.Service.ID), nil) options := structs.QueryOptions{Token: testToken} ctx, err := external.ContextWithQueryOptions(context.Background(), options) require.NoError(t, err) server := NewServer(Config{ GetStore: func() StateStore { return store }, Logger: hclog.NewNullLogger(), ACLResolver: aclResolver, Datacenter: serverDC, }) client := testClient(t, server) req := &pbdataplane.GetEnvoyBootstrapParamsRequest{ ServiceId: tc.registerReq.Service.ID, NodeSpec: &pbdataplane.GetEnvoyBootstrapParamsRequest_NodeName{NodeName: tc.registerReq.Node}} if tc.nodeID { req.NodeSpec = &pbdataplane.GetEnvoyBootstrapParamsRequest_NodeId{NodeId: string(tc.registerReq.ID)} } resp, err := client.GetEnvoyBootstrapParams(ctx, req) require.NoError(t, err) if tc.registerReq.Service.IsGateway() { require.Equal(t, tc.registerReq.Service.Service, resp.Service) } else { require.Equal(t, tc.registerReq.Service.Proxy.DestinationServiceName, resp.Service) } require.Equal(t, serverDC, resp.Datacenter) require.Equal(t, tc.registerReq.EnterpriseMeta.PartitionOrDefault(), resp.Partition) require.Equal(t, tc.registerReq.EnterpriseMeta.NamespaceOrDefault(), resp.Namespace) requireConfigField(t, resp, proxyConfigKey, structpb.NewStringValue(proxyConfigValue)) require.Equal(t, tc.registerReq.Node, resp.NodeName) if tc.serviceDefaults != nil && tc.proxyDefaults != nil { // service-defaults take precedence over proxy-defaults requireConfigField(t, resp, protocolKey, structpb.NewStringValue(serviceDefaultsProtocol)) requireConfigField(t, resp, connectTimeoutKey, structpb.NewNumberValue(serviceDefaultsConnectTimeout)) requireConfigField(t, resp, requestTimeoutKey, structpb.NewNumberValue(proxyDefaultsRequestTimeout)) } else if tc.serviceDefaults != nil { requireConfigField(t, resp, protocolKey, structpb.NewStringValue(serviceDefaultsProtocol)) requireConfigField(t, resp, connectTimeoutKey, structpb.NewNumberValue(serviceDefaultsConnectTimeout)) } else if tc.proxyDefaults != nil { requireConfigField(t, resp, protocolKey, structpb.NewStringValue(proxyDefaultsProtocol)) requireConfigField(t, resp, requestTimeoutKey, structpb.NewNumberValue(proxyDefaultsRequestTimeout)) } if tc.proxyDefaults != nil { pd, ok := tc.proxyDefaults.(*structs.ProxyConfigEntry) require.True(t, ok, "Invalid Proxy Defaults") if pd.AccessLogs.Enabled { require.JSONEq(t, "{\"name\":\"Consul Listener Filter Log\",\"typedConfig\":{\"@type\":\"type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog\",\"logFormat\":{\"jsonFormat\":{\"custom_field\":\"%START_TIME%\"}}}}", resp.AccessLogs[0]) } } } testCases := []testCase{ { name: "lookup service sidecar proxy by node name", registerReq: testRegisterRequestProxy(t), }, { name: "lookup service sidecar proxy by node ID", registerReq: testRegisterRequestProxy(t), nodeID: true, }, { name: "lookup ingress gw service by node name", registerReq: testRegisterIngressGateway(t), }, { name: "lookup ingress gw service by node ID", registerReq: testRegisterIngressGateway(t), nodeID: true, }, { name: "merge proxy defaults for sidecar proxy", registerReq: testRegisterRequestProxy(t), proxyDefaults: testProxyDefaults(t, false), }, { name: "proxy defaults access logs", registerReq: testRegisterRequestProxy(t), proxyDefaults: testProxyDefaults(t, true), }, { name: "merge service defaults for sidecar proxy", registerReq: testRegisterRequestProxy(t), serviceDefaults: testServiceDefaults(t), }, { name: "merge proxy defaults and service defaults for sidecar proxy", registerReq: testRegisterRequestProxy(t), serviceDefaults: testServiceDefaults(t), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { run(t, tc) }) } } func TestGetEnvoyBootstrapParams_Success_EnableV2(t *testing.T) { type testCase struct { name string workloadData *pbcatalog.Workload proxyCfg *pbmesh.ComputedProxyConfiguration expBootstrapCfg *pbmesh.BootstrapConfig expAccessLogs string } run := func(t *testing.T, tc testCase) { resourceClient := svctest.RunResourceService(t, catalog.RegisterTypes, mesh.RegisterTypes) options := structs.QueryOptions{Token: testToken} ctx, err := external.ContextWithQueryOptions(context.Background(), options) require.NoError(t, err) aclResolver := &MockACLResolver{} server := NewServer(Config{ Logger: hclog.NewNullLogger(), ACLResolver: aclResolver, Datacenter: serverDC, EnableV2: true, ResourceAPIClient: resourceClient, }) client := testClient(t, server) // Add required fields to workload data. tc.workloadData.Addresses = []*pbcatalog.WorkloadAddress{ { Host: "127.0.0.1", }, } tc.workloadData.Ports = map[string]*pbcatalog.WorkloadPort{ "tcp": {Port: 8080, Protocol: pbcatalog.Protocol_PROTOCOL_TCP}, } workloadResource := resourcetest.Resource(pbcatalog.WorkloadType, "test-workload"). WithData(t, tc.workloadData). WithTenancy(resource.DefaultNamespacedTenancy()). Write(t, resourceClient) // Create computed proxy cfg resource. resourcetest.Resource(pbmesh.ComputedProxyConfigurationType, workloadResource.Id.Name). WithData(t, tc.proxyCfg). WithTenancy(resource.DefaultNamespacedTenancy()). Write(t, resourceClient) req := &pbdataplane.GetEnvoyBootstrapParamsRequest{ ProxyId: workloadResource.Id.Name, Namespace: workloadResource.Id.Tenancy.Namespace, Partition: workloadResource.Id.Tenancy.Partition, } aclResolver.On("ResolveTokenAndDefaultMeta", testToken, mock.Anything, mock.Anything). Return(testutils.ACLUseProvidedPolicy(t, &acl.Policy{ PolicyRules: acl.PolicyRules{ Services: []*acl.ServiceRule{ { Name: workloadResource.Id.Name, Policy: acl.PolicyRead, }, }, Identities: []*acl.IdentityRule{ { Name: testIdentity, Policy: acl.PolicyWrite, }, }, }, }), nil) resp, err := client.GetEnvoyBootstrapParams(ctx, req) require.NoError(t, err) require.Equal(t, tc.workloadData.Identity, resp.Identity) require.Equal(t, serverDC, resp.Datacenter) require.Equal(t, workloadResource.Id.Tenancy.Partition, resp.Partition) require.Equal(t, workloadResource.Id.Tenancy.Namespace, resp.Namespace) require.Equal(t, resp.NodeName, tc.workloadData.NodeName) prototest.AssertDeepEqual(t, tc.expBootstrapCfg, resp.BootstrapConfig) if tc.expAccessLogs != "" { require.JSONEq(t, tc.expAccessLogs, resp.AccessLogs[0]) } } testCases := []testCase{ { name: "workload without node", workloadData: &pbcatalog.Workload{ Identity: testIdentity, }, expBootstrapCfg: nil, }, { name: "workload with node", workloadData: &pbcatalog.Workload{ Identity: testIdentity, NodeName: "test-node", }, expBootstrapCfg: nil, }, { name: "single proxy configuration", workloadData: &pbcatalog.Workload{ Identity: testIdentity, }, proxyCfg: &pbmesh.ComputedProxyConfiguration{ BootstrapConfig: &pbmesh.BootstrapConfig{ DogstatsdUrl: "dogstats-url", }, }, expBootstrapCfg: &pbmesh.BootstrapConfig{ DogstatsdUrl: "dogstats-url", }, }, { name: "multiple proxy configurations", workloadData: &pbcatalog.Workload{ Identity: testIdentity, }, proxyCfg: &pbmesh.ComputedProxyConfiguration{ BootstrapConfig: &pbmesh.BootstrapConfig{ DogstatsdUrl: "dogstats-url", StatsdUrl: "stats-url", }, DynamicConfig: &pbmesh.DynamicConfig{ AccessLogs: &pbmesh.AccessLogsConfig{ Enabled: true, JsonFormat: "{ \"custom_field\": \"%START_TIME%\" }", }, }, }, expBootstrapCfg: &pbmesh.BootstrapConfig{ DogstatsdUrl: "dogstats-url", StatsdUrl: "stats-url", }, expAccessLogs: testAccessLogs, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { run(t, tc) }) } } func TestGetEnvoyBootstrapParams_Error(t *testing.T) { type testCase struct { name string req *pbdataplane.GetEnvoyBootstrapParamsRequest expectedErrCode codes.Code expecteErrMsg string } run := func(t *testing.T, tc testCase) { aclResolver := &MockACLResolver{} aclResolver.On("ResolveTokenAndDefaultMeta", testToken, mock.Anything, mock.Anything). Return(testutils.ACLServiceRead(t, proxyServiceID), nil) options := structs.QueryOptions{Token: testToken} ctx, err := external.ContextWithQueryOptions(context.Background(), options) require.NoError(t, err) store := testutils.TestStateStore(t, nil) registerReq := testRegisterRequestProxy(t) err = store.EnsureRegistration(1, registerReq) require.NoError(t, err) server := NewServer(Config{ GetStore: func() StateStore { return store }, Logger: hclog.NewNullLogger(), ACLResolver: aclResolver, }) client := testClient(t, server) resp, err := client.GetEnvoyBootstrapParams(ctx, tc.req) require.Nil(t, resp) require.Error(t, err) errStatus, ok := status.FromError(err) require.True(t, ok) require.Equal(t, tc.expectedErrCode.String(), errStatus.Code().String()) require.Equal(t, tc.expecteErrMsg, errStatus.Message()) } testCases := []testCase{ { name: "lookup-service-by-unregistered-node-name", req: &pbdataplane.GetEnvoyBootstrapParamsRequest{ ServiceId: proxyServiceID, NodeSpec: &pbdataplane.GetEnvoyBootstrapParamsRequest_NodeName{NodeName: "blah"}}, expectedErrCode: codes.NotFound, expecteErrMsg: "node not found", }, { name: "lookup-service-by-unregistered-node-id", req: &pbdataplane.GetEnvoyBootstrapParamsRequest{ ServiceId: proxyServiceID, NodeSpec: &pbdataplane.GetEnvoyBootstrapParamsRequest_NodeId{NodeId: "5980b72b-bd9d-9d7b-d4f9-951bf7508d98"}}, expectedErrCode: codes.NotFound, expecteErrMsg: "node not found", }, { name: "lookup-service-by-unregistered-service", req: &pbdataplane.GetEnvoyBootstrapParamsRequest{ ServiceId: "blah-service", NodeSpec: &pbdataplane.GetEnvoyBootstrapParamsRequest_NodeName{NodeName: nodeName}}, expectedErrCode: codes.NotFound, expecteErrMsg: "Service not found", }, { name: "lookup-service-without-node-details", req: &pbdataplane.GetEnvoyBootstrapParamsRequest{ ServiceId: proxyServiceID}, expectedErrCode: codes.InvalidArgument, expecteErrMsg: "Node ID or name required to lookup the service", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { run(t, tc) }) } } func TestGetEnvoyBootstrapParams_Error_EnableV2(t *testing.T) { type testCase struct { name string expectedErrCode codes.Code expecteErrMsg string workload *pbresource.Resource } run := func(t *testing.T, tc testCase) { resourceClient := svctest.RunResourceService(t, catalog.RegisterTypes, mesh.RegisterTypes) options := structs.QueryOptions{Token: testToken} ctx, err := external.ContextWithQueryOptions(context.Background(), options) require.NoError(t, err) aclResolver := &MockACLResolver{} aclResolver.On("ResolveTokenAndDefaultMeta", testToken, mock.Anything, mock.Anything). Return(testutils.ACLServiceRead(t, "doesn't matter"), nil) server := NewServer(Config{ Logger: hclog.NewNullLogger(), ACLResolver: aclResolver, Datacenter: serverDC, EnableV2: true, ResourceAPIClient: resourceClient, }) client := testClient(t, server) var req pbdataplane.GetEnvoyBootstrapParamsRequest // Write the workload resource. if tc.workload != nil { _, err = resourceClient.Write(context.Background(), &pbresource.WriteRequest{ Resource: tc.workload, }) require.NoError(t, err) req = pbdataplane.GetEnvoyBootstrapParamsRequest{ ProxyId: tc.workload.Id.Name, Namespace: tc.workload.Id.Tenancy.Namespace, Partition: tc.workload.Id.Tenancy.Partition, } } else { req = pbdataplane.GetEnvoyBootstrapParamsRequest{ ProxyId: "not-found", Namespace: "default", Partition: "default", } } resp, err := client.GetEnvoyBootstrapParams(ctx, &req) require.Nil(t, resp) require.Error(t, err) errStatus, ok := status.FromError(err) require.True(t, ok) require.Equal(t, tc.expectedErrCode.String(), errStatus.Code().String()) require.Equal(t, tc.expecteErrMsg, errStatus.Message()) } workload := resourcetest.Resource(pbcatalog.WorkloadType, "test-workload"). WithData(t, &pbcatalog.Workload{ Addresses: []*pbcatalog.WorkloadAddress{ {Host: "127.0.0.1"}, }, Ports: map[string]*pbcatalog.WorkloadPort{ "tcp": {Port: 8080}, }, }). WithTenancy(resource.DefaultNamespacedTenancy()). Build() testCases := []testCase{ { name: "workload doesn't exist", expectedErrCode: codes.NotFound, expecteErrMsg: "resource not found", }, { name: "workload without identity", expectedErrCode: codes.InvalidArgument, expecteErrMsg: "workload \"test-workload\" doesn't have identity associated with it", workload: workload, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { run(t, tc) }) } } func TestGetEnvoyBootstrapParams_Unauthenticated(t *testing.T) { // Mock the ACL resolver to return ErrNotFound. aclResolver := &MockACLResolver{} aclResolver.On("ResolveTokenAndDefaultMeta", mock.Anything, mock.Anything, mock.Anything). Return(resolver.Result{}, acl.ErrNotFound) options := structs.QueryOptions{Token: testToken} ctx, err := external.ContextWithQueryOptions(context.Background(), options) require.NoError(t, err) store := testutils.TestStateStore(t, nil) server := NewServer(Config{ GetStore: func() StateStore { return store }, Logger: hclog.NewNullLogger(), ACLResolver: aclResolver, }) client := testClient(t, server) resp, err := client.GetEnvoyBootstrapParams(ctx, &pbdataplane.GetEnvoyBootstrapParamsRequest{}) require.Error(t, err) require.Equal(t, codes.Unauthenticated.String(), status.Code(err).String()) require.Nil(t, resp) } func TestGetEnvoyBootstrapParams_PermissionDenied(t *testing.T) { // Mock the ACL resolver to return a deny all authorizer aclResolver := &MockACLResolver{} aclResolver.On("ResolveTokenAndDefaultMeta", testToken, mock.Anything, mock.Anything). Return(testutils.ACLNoPermissions(t), nil) options := structs.QueryOptions{Token: testToken} ctx, err := external.ContextWithQueryOptions(context.Background(), options) require.NoError(t, err) store := testutils.TestStateStore(t, nil) registerReq := structs.TestRegisterRequestProxy(t) proxyServiceID := "web-sidecar-proxy" registerReq.Service.ID = proxyServiceID err = store.EnsureRegistration(1, registerReq) require.NoError(t, err) server := NewServer(Config{ GetStore: func() StateStore { return store }, Logger: hclog.NewNullLogger(), ACLResolver: aclResolver, }) client := testClient(t, server) req := &pbdataplane.GetEnvoyBootstrapParamsRequest{ ServiceId: proxyServiceID, NodeSpec: &pbdataplane.GetEnvoyBootstrapParamsRequest_NodeName{NodeName: registerReq.Node}} resp, err := client.GetEnvoyBootstrapParams(ctx, req) require.Error(t, err) require.Equal(t, codes.PermissionDenied.String(), status.Code(err).String()) require.Nil(t, resp) }