diff --git a/agent/consul/helper_test.go b/agent/consul/helper_test.go index 2db49f667b..7167fa35f6 100644 --- a/agent/consul/helper_test.go +++ b/agent/consul/helper_test.go @@ -7,14 +7,15 @@ import ( "net/rpc" "testing" - "github.com/hashicorp/consul/agent/structs" - "github.com/hashicorp/consul/api" - "github.com/hashicorp/consul/sdk/testutil/retry" - "github.com/hashicorp/consul/types" msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc" "github.com/hashicorp/raft" "github.com/hashicorp/serf/serf" "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/sdk/testutil/retry" + "github.com/hashicorp/consul/types" ) func waitForLeader(servers ...*Server) error { @@ -976,6 +977,196 @@ func registerTestTopologyEntries(t *testing.T, codec rpc.ClientCodec, token stri } } +func registerTestRoutingConfigTopologyEntries(t *testing.T, codec rpc.ClientCodec) { + registrations := map[string]*structs.RegisterRequest{ + "Service dashboard": { + Datacenter: "dc1", + Node: "foo", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindTypical, + ID: "dashboard", + Service: "dashboard", + Port: 9002, + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "foo", + CheckID: "foo:dashboard", + Status: api.HealthPassing, + ServiceID: "dashboard", + ServiceName: "dashboard", + }, + }, + }, + "Service dashboard-proxy": { + Datacenter: "dc1", + Node: "foo", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindConnectProxy, + ID: "dashboard-sidecar-proxy", + Service: "dashboard-sidecar-proxy", + Port: 5000, + Address: "198.18.1.0", + Proxy: structs.ConnectProxyConfig{ + DestinationServiceName: "dashboard", + DestinationServiceID: "dashboard", + LocalServiceAddress: "127.0.0.1", + LocalServicePort: 9002, + Upstreams: []structs.Upstream{ + { + DestinationType: "service", + DestinationName: "routing-config", + LocalBindPort: 5000, + }, + }, + }, + LocallyRegisteredAsSidecar: true, + }, + }, + "Service counting": { + Datacenter: "dc1", + Node: "foo", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindTypical, + ID: "counting", + Service: "counting", + Port: 9003, + Address: "198.18.1.1", + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "foo", + CheckID: "foo:api", + Status: api.HealthPassing, + ServiceID: "counting", + ServiceName: "counting", + }, + }, + }, + "Service counting-proxy": { + Datacenter: "dc1", + Node: "foo", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindConnectProxy, + ID: "counting-proxy", + Service: "counting-proxy", + Port: 5001, + Address: "198.18.1.1", + Proxy: structs.ConnectProxyConfig{ + DestinationServiceName: "counting", + }, + LocallyRegisteredAsSidecar: true, + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "foo", + CheckID: "foo:counting-proxy", + Status: api.HealthPassing, + ServiceID: "counting-proxy", + ServiceName: "counting-proxy", + }, + }, + }, + "Service counting-v2": { + Datacenter: "dc1", + Node: "foo", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindTypical, + ID: "counting-v2", + Service: "counting-v2", + Port: 9004, + Address: "198.18.1.2", + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "foo", + CheckID: "foo:api", + Status: api.HealthPassing, + ServiceID: "counting-v2", + ServiceName: "counting-v2", + }, + }, + }, + "Service counting-v2-proxy": { + Datacenter: "dc1", + Node: "foo", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindConnectProxy, + ID: "counting-v2-proxy", + Service: "counting-v2-proxy", + Port: 5002, + Address: "198.18.1.2", + Proxy: structs.ConnectProxyConfig{ + DestinationServiceName: "counting-v2", + }, + LocallyRegisteredAsSidecar: true, + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "foo", + CheckID: "foo:counting-v2-proxy", + Status: api.HealthPassing, + ServiceID: "counting-v2-proxy", + ServiceName: "counting-v2-proxy", + }, + }, + }, + } + registerTestCatalogEntriesMap(t, codec, registrations) + + entries := []structs.ConfigEntryRequest{ + { + Datacenter: "dc1", + Entry: &structs.ProxyConfigEntry{ + Kind: structs.ProxyDefaults, + Name: structs.ProxyConfigGlobal, + Config: map[string]interface{}{ + "protocol": "http", + }, + }, + }, + { + Datacenter: "dc1", + Entry: &structs.ServiceRouterConfigEntry{ + Kind: structs.ServiceRouter, + Name: "routing-config", + Routes: []structs.ServiceRoute{ + { + Match: &structs.ServiceRouteMatch{ + HTTP: &structs.ServiceRouteHTTPMatch{ + PathPrefix: "/v2", + }, + }, + Destination: &structs.ServiceRouteDestination{ + Service: "counting-v2", + }, + }, + { + Match: &structs.ServiceRouteMatch{ + HTTP: &structs.ServiceRouteHTTPMatch{ + PathPrefix: "/", + }, + }, + Destination: &structs.ServiceRouteDestination{ + Service: "counting", + }, + }, + }, + }, + }, + } + for _, req := range entries { + var out bool + require.NoError(t, msgpackrpc.CallWithCodec(codec, "ConfigEntry.Apply", &req, &out)) + } +} + func registerIntentionUpstreamEntries(t *testing.T, codec rpc.ClientCodec, token string) { t.Helper() diff --git a/agent/consul/internal_endpoint_test.go b/agent/consul/internal_endpoint_test.go index 5758aa89f7..14b39794dc 100644 --- a/agent/consul/internal_endpoint_test.go +++ b/agent/consul/internal_endpoint_test.go @@ -1902,6 +1902,58 @@ func TestInternal_ServiceTopology(t *testing.T) { }) } +func TestInternal_ServiceTopology_RoutingConfig(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + dir1, s1 := testServer(t) + defer os.RemoveAll(dir1) + defer s1.Shutdown() + + testrpc.WaitForLeader(t, s1.RPC, "dc1") + + codec := rpcClient(t, s1) + defer codec.Close() + + // dashboard -> routing-config -> { counting, counting-v2 } + registerTestRoutingConfigTopologyEntries(t, codec) + + t.Run("dashboard", func(t *testing.T) { + retry.Run(t, func(r *retry.R) { + args := structs.ServiceSpecificRequest{ + Datacenter: "dc1", + ServiceName: "dashboard", + } + var out structs.IndexedServiceTopology + require.NoError(r, msgpackrpc.CallWithCodec(codec, "Internal.ServiceTopology", &args, &out)) + require.False(r, out.FilteredByACLs) + require.Equal(r, "http", out.ServiceTopology.MetricsProtocol) + + require.Empty(r, out.ServiceTopology.Downstreams) + require.Empty(r, out.ServiceTopology.DownstreamDecisions) + require.Empty(r, out.ServiceTopology.DownstreamSources) + + // routing-config will not appear as an Upstream service + // but will be present in UpstreamSources as a k-v pair. + require.Empty(r, out.ServiceTopology.Upstreams) + + expectUp := map[string]structs.IntentionDecisionSummary{ + "routing-config": {DefaultAllow: true, Allowed: true}, + } + require.Equal(r, expectUp, out.ServiceTopology.UpstreamDecisions) + + expectUpstreamSources := map[string]string{ + "routing-config": structs.TopologySourceRoutingConfig, + } + require.Equal(r, expectUpstreamSources, out.ServiceTopology.UpstreamSources) + + require.False(r, out.ServiceTopology.TransparentProxy) + }) + }) +} + func TestInternal_ServiceTopology_ACL(t *testing.T) { if testing.Short() { t.Skip("too slow for testing.Short") diff --git a/agent/consul/state/catalog.go b/agent/consul/state/catalog.go index 24319da1b4..927455c563 100644 --- a/agent/consul/state/catalog.go +++ b/agent/consul/state/catalog.go @@ -3092,44 +3092,67 @@ func (s *Store) ServiceTopology( maxIdx = idx } - var ( - seenUpstreams = make(map[string]struct{}) - upstreamSources = make(map[string]string) - ) + var upstreamSources = make(map[string]string) for _, un := range upstreamNames { - if _, ok := seenUpstreams[un.String()]; !ok { - seenUpstreams[un.String()] = struct{}{} - } upstreamSources[un.String()] = structs.TopologySourceRegistration } - idx, intentionUpstreams, err := s.intentionTopologyTxn(tx, ws, sn, false, defaultAllow) - if err != nil { - return 0, nil, err - } - if idx > maxIdx { - maxIdx = idx - } - upstreamDecisions := make(map[string]structs.IntentionDecisionSummary) - for _, svc := range intentionUpstreams { - if _, ok := seenUpstreams[svc.Name.String()]; ok { - // Avoid duplicating entry - continue - } - upstreamDecisions[svc.Name.String()] = svc.Decision - upstreamNames = append(upstreamNames, svc.Name) - var source string - switch { - case svc.Decision.HasExact: - source = structs.TopologySourceSpecificIntention - case svc.Decision.DefaultAllow: - source = structs.TopologySourceDefaultAllow - default: - source = structs.TopologySourceWildcardIntention + // Only transparent proxies have upstreams from intentions + if hasTransparent { + idx, intentionUpstreams, err := s.intentionTopologyTxn(tx, ws, sn, false, defaultAllow) + if err != nil { + return 0, nil, err } - upstreamSources[svc.Name.String()] = source + if idx > maxIdx { + maxIdx = idx + } + + for _, svc := range intentionUpstreams { + if _, ok := upstreamSources[svc.Name.String()]; ok { + // Avoid duplicating entry + continue + } + upstreamDecisions[svc.Name.String()] = svc.Decision + upstreamNames = append(upstreamNames, svc.Name) + + var source string + switch { + case svc.Decision.HasExact: + source = structs.TopologySourceSpecificIntention + case svc.Decision.DefaultAllow: + source = structs.TopologySourceDefaultAllow + default: + source = structs.TopologySourceWildcardIntention + } + upstreamSources[svc.Name.String()] = source + } + } + + matchEntry := structs.IntentionMatchEntry{ + Namespace: entMeta.NamespaceOrDefault(), + Name: service, + } + _, srcIntentions, err := compatIntentionMatchOneTxn( + tx, + ws, + matchEntry, + + // The given service is a source relative to its upstreams + structs.IntentionMatchSource, + ) + if err != nil { + return 0, nil, fmt.Errorf("failed to query intentions for %s", sn.String()) + } + + for _, un := range upstreamNames { + decision, err := s.IntentionDecision(un.Name, un.NamespaceOrDefault(), srcIntentions, structs.IntentionMatchDestination, defaultAllow, false) + if err != nil { + return 0, nil, fmt.Errorf("failed to get intention decision from (%s) to (%s): %v", + sn.String(), un.String(), err) + } + upstreamDecisions[un.String()] = decision } idx, unfilteredUpstreams, err := s.combinedServiceNodesTxn(tx, ws, upstreamNames) @@ -3154,28 +3177,29 @@ func (s *Store) ServiceTopology( upstreams = append(upstreams, upstream) } - matchEntry := structs.IntentionMatchEntry{ - Namespace: entMeta.NamespaceOrDefault(), - Name: service, + var foundUpstreams = make(map[structs.ServiceName]struct{}) + for _, csn := range upstreams { + foundUpstreams[csn.Service.CompoundServiceName()] = struct{}{} } - _, srcIntentions, err := compatIntentionMatchOneTxn( - tx, - ws, - matchEntry, - // The given service is a source relative to its upstreams - structs.IntentionMatchSource, - ) - if err != nil { - return 0, nil, fmt.Errorf("failed to query intentions for %s", sn.String()) - } + // Check upstream names that had no service instances to see if they are routing config. for _, un := range upstreamNames { - decision, err := s.IntentionDecision(un.Name, un.NamespaceOrDefault(), srcIntentions, structs.IntentionMatchDestination, defaultAllow, false) - if err != nil { - return 0, nil, fmt.Errorf("failed to get intention decision from (%s) to (%s): %v", - sn.String(), un.String(), err) + if _, ok := foundUpstreams[un]; ok { + continue + } + + for _, kind := range serviceGraphKinds { + idx, entry, err := configEntryTxn(tx, ws, kind, un.Name, &un.EnterpriseMeta) + if err != nil { + return 0, nil, err + } + if entry != nil { + upstreamSources[un.String()] = structs.TopologySourceRoutingConfig + } + if idx > maxIdx { + maxIdx = idx + } } - upstreamDecisions[un.String()] = decision } idx, downstreamNames, err := s.downstreamsForServiceTxn(tx, ws, dc, sn) @@ -3186,14 +3210,8 @@ func (s *Store) ServiceTopology( maxIdx = idx } - var ( - seenDownstreams = make(map[string]struct{}) - downstreamSources = make(map[string]string) - ) + var downstreamSources = make(map[string]string) for _, dn := range downstreamNames { - if _, ok := seenDownstreams[dn.String()]; !ok { - seenDownstreams[dn.String()] = struct{}{} - } downstreamSources[dn.String()] = structs.TopologySourceRegistration } @@ -3207,7 +3225,7 @@ func (s *Store) ServiceTopology( downstreamDecisions := make(map[string]structs.IntentionDecisionSummary) for _, svc := range intentionDownstreams { - if _, ok := seenDownstreams[svc.Name.String()]; ok { + if _, ok := downstreamSources[svc.Name.String()]; ok { // Avoid duplicating entry continue } @@ -3226,6 +3244,26 @@ func (s *Store) ServiceTopology( downstreamSources[svc.Name.String()] = source } + _, dstIntentions, err := compatIntentionMatchOneTxn( + tx, + ws, + matchEntry, + + // The given service is a destination relative to its downstreams + structs.IntentionMatchDestination, + ) + if err != nil { + return 0, nil, fmt.Errorf("failed to query intentions for %s", sn.String()) + } + for _, dn := range downstreamNames { + decision, err := s.IntentionDecision(dn.Name, dn.NamespaceOrDefault(), dstIntentions, structs.IntentionMatchSource, defaultAllow, false) + if err != nil { + return 0, nil, fmt.Errorf("failed to get intention decision from (%s) to (%s): %v", + dn.String(), sn.String(), err) + } + downstreamDecisions[dn.String()] = decision + } + idx, unfilteredDownstreams, err := s.combinedServiceNodesTxn(tx, ws, downstreamNames) if err != nil { return 0, nil, fmt.Errorf("failed to get downstreams for %q: %v", sn.String(), err) @@ -3251,31 +3289,14 @@ func (s *Store) ServiceTopology( sn = structs.NewServiceName(downstream.Service.Proxy.DestinationServiceName, &downstream.Service.EnterpriseMeta) } if _, ok := tproxyMap[sn]; !ok && downstreamSources[sn.String()] != structs.TopologySourceRegistration { + // If downstream is not a transparent proxy, remove references + delete(downstreamSources, sn.String()) + delete(downstreamDecisions, sn.String()) continue } downstreams = append(downstreams, downstream) } - _, dstIntentions, err := compatIntentionMatchOneTxn( - tx, - ws, - matchEntry, - - // The given service is a destination relative to its downstreams - structs.IntentionMatchDestination, - ) - if err != nil { - return 0, nil, fmt.Errorf("failed to query intentions for %s", sn.String()) - } - for _, dn := range downstreamNames { - decision, err := s.IntentionDecision(dn.Name, dn.NamespaceOrDefault(), dstIntentions, structs.IntentionMatchSource, defaultAllow, false) - if err != nil { - return 0, nil, fmt.Errorf("failed to get intention decision from (%s) to (%s): %v", - dn.String(), sn.String(), err) - } - downstreamDecisions[dn.String()] = decision - } - resp := &structs.ServiceTopology{ TransparentProxy: fullyTransparent, MetricsProtocol: protocol, diff --git a/agent/structs/connect_proxy_config.go b/agent/structs/connect_proxy_config.go index d8ad467977..984adfb28b 100644 --- a/agent/structs/connect_proxy_config.go +++ b/agent/structs/connect_proxy_config.go @@ -39,17 +39,21 @@ const ( const ( // TODO (freddy) Should we have a TopologySourceMixed when there is a mix of proxy reg and tproxy? // Currently we label as proxy-registration if ANY instance has the explicit upstream definition. - // TopologySourceRegistration is used to label upstreams or downstreams from explicit upstream definitions + // TopologySourceRegistration is used to label upstreams or downstreams from explicit upstream definitions. TopologySourceRegistration = "proxy-registration" - // TopologySourceSpecificIntention is used to label upstreams or downstreams from specific intentions + // TopologySourceSpecificIntention is used to label upstreams or downstreams from specific intentions. TopologySourceSpecificIntention = "specific-intention" - // TopologySourceWildcardIntention is used to label upstreams or downstreams from wildcard intentions + // TopologySourceWildcardIntention is used to label upstreams or downstreams from wildcard intentions. TopologySourceWildcardIntention = "wildcard-intention" - // TopologySourceDefaultAllow is used to label upstreams or downstreams from default allow ACL policy + // TopologySourceDefaultAllow is used to label upstreams or downstreams from default allow ACL policy. TopologySourceDefaultAllow = "default-allow" + + // TopologySourceRoutingConfig is used to label upstreams that are not backed by a service instance + // and are simply used for routing configurations. + TopologySourceRoutingConfig = "routing-config" ) // MeshGatewayConfig controls how Mesh Gateways are configured and used diff --git a/agent/ui_endpoint.go b/agent/ui_endpoint.go index 0564dc0f7f..4bb9eab165 100644 --- a/agent/ui_endpoint.go +++ b/agent/ui_endpoint.go @@ -343,6 +343,21 @@ RPC: } upstreamResp = append(upstreamResp, &sum) } + for k, v := range out.ServiceTopology.UpstreamSources { + if v == structs.TopologySourceRoutingConfig { + sn := structs.ServiceNameFromString(k) + sum := ServiceTopologySummary{ + ServiceSummary: ServiceSummary{ + Datacenter: args.Datacenter, + Name: sn.Name, + EnterpriseMeta: sn.EnterpriseMeta, + }, + Intention: out.ServiceTopology.UpstreamDecisions[sn.String()], + Source: out.ServiceTopology.UpstreamSources[sn.String()], + } + upstreamResp = append(upstreamResp, &sum) + } + } sortedDownstreams := prepSummaryOutput(downstreams, true) for _, svc := range sortedDownstreams { diff --git a/agent/ui_endpoint_test.go b/agent/ui_endpoint_test.go index b9ef3f0acf..1734e0340d 100644 --- a/agent/ui_endpoint_test.go +++ b/agent/ui_endpoint_test.go @@ -12,15 +12,16 @@ import ( "sync/atomic" "testing" + cleanhttp "github.com/hashicorp/go-cleanhttp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/hashicorp/consul/agent/config" "github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/sdk/testutil" "github.com/hashicorp/consul/sdk/testutil/retry" "github.com/hashicorp/consul/testrpc" - cleanhttp "github.com/hashicorp/go-cleanhttp" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestUiIndex(t *testing.T) { @@ -1397,34 +1398,59 @@ func TestUIServiceTopology(t *testing.T) { } } - t.Run("request without kind", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/ingress", nil) - resp := httptest.NewRecorder() - obj, err := a.srv.UIServiceTopology(resp, req) - require.Nil(t, err) - require.Nil(t, obj) - require.Equal(t, "Missing service kind", resp.Body.String()) - }) + type testCase struct { + name string + httpReq *http.Request + want *ServiceTopology + wantErr string + } - t.Run("request with unsupported kind", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/ingress?kind=not-a-kind", nil) - resp := httptest.NewRecorder() - obj, err := a.srv.UIServiceTopology(resp, req) - require.Nil(t, err) - require.Nil(t, obj) - require.Equal(t, `Unsupported service kind "not-a-kind"`, resp.Body.String()) - }) - - t.Run("ingress", func(t *testing.T) { + run := func(t *testing.T, tc testCase) { retry.Run(t, func(r *retry.R) { - // Request topology for ingress - req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/ingress?kind=ingress-gateway", nil) resp := httptest.NewRecorder() - obj, err := a.srv.UIServiceTopology(resp, req) + obj, err := a.srv.UIServiceTopology(resp, tc.httpReq) assert.Nil(r, err) - require.NoError(r, checkIndex(resp)) - expect := ServiceTopology{ + if tc.wantErr != "" { + assert.Nil(r, tc.want) // should not define a non-nil want + require.Equal(r, tc.wantErr, resp.Body.String()) + require.Nil(r, obj) + return + } + + require.NoError(r, checkIndex(resp)) + require.NotNil(r, obj) + result := obj.(ServiceTopology) + clearUnexportedFields(result) + + require.Equal(r, *tc.want, result) + }) + } + + tcs := []testCase{ + { + name: "request without kind", + httpReq: func() *http.Request { + req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/ingress", nil) + return req + }(), + wantErr: "Missing service kind", + }, + { + name: "request with unsupported kind", + httpReq: func() *http.Request { + req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/ingress?kind=not-a-kind", nil) + return req + }(), + wantErr: `Unsupported service kind "not-a-kind"`, + }, + { + name: "ingress", + httpReq: func() *http.Request { + req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/ingress?kind=ingress-gateway", nil) + return req + }(), + want: &ServiceTopology{ Protocol: "tcp", TransparentProxy: false, Upstreams: []*ServiceTopologySummary{ @@ -1449,29 +1475,15 @@ func TestUIServiceTopology(t *testing.T) { }, Downstreams: []*ServiceTopologySummary{}, FilteredByACLs: false, - } - result := obj.(ServiceTopology) - - // Internal accounting that is not returned in JSON response - for _, u := range result.Upstreams { - u.externalSourceSet = nil - u.checks = nil - u.transparentProxySet = false - } - require.Equal(r, expect, result) - }) - }) - - t.Run("api", func(t *testing.T) { - retry.Run(t, func(r *retry.R) { - // Request topology for api - req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/api?kind=", nil) - resp := httptest.NewRecorder() - obj, err := a.srv.UIServiceTopology(resp, req) - assert.Nil(r, err) - require.NoError(r, checkIndex(resp)) - - expect := ServiceTopology{ + }, + }, + { + name: "api", + httpReq: func() *http.Request { + req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/api?kind=", nil) + return req + }(), + want: &ServiceTopology{ Protocol: "tcp", TransparentProxy: true, Downstreams: []*ServiceTopologySummary{ @@ -1518,34 +1530,15 @@ func TestUIServiceTopology(t *testing.T) { }, }, FilteredByACLs: false, - } - result := obj.(ServiceTopology) - - // Internal accounting that is not returned in JSON response - for _, u := range result.Upstreams { - u.transparentProxySet = false - u.externalSourceSet = nil - u.checks = nil - } - for _, d := range result.Downstreams { - d.transparentProxySet = false - d.externalSourceSet = nil - d.checks = nil - } - require.Equal(r, expect, result) - }) - }) - - t.Run("web", func(t *testing.T) { - retry.Run(t, func(r *retry.R) { - // Request topology for web - req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/web?kind=", nil) - resp := httptest.NewRecorder() - obj, err := a.srv.UIServiceTopology(resp, req) - assert.Nil(r, err) - require.NoError(r, checkIndex(resp)) - - expect := ServiceTopology{ + }, + }, + { + name: "web", + httpReq: func() *http.Request { + req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/web?kind=", nil) + return req + }(), + want: &ServiceTopology{ Protocol: "http", TransparentProxy: false, Upstreams: []*ServiceTopologySummary{ @@ -1590,36 +1583,18 @@ func TestUIServiceTopology(t *testing.T) { }, }, FilteredByACLs: false, - } - result := obj.(ServiceTopology) - - // Internal accounting that is not returned in JSON response - for _, u := range result.Upstreams { - u.transparentProxySet = false - u.externalSourceSet = nil - u.checks = nil - } - for _, d := range result.Downstreams { - d.transparentProxySet = false - d.externalSourceSet = nil - d.checks = nil - } - require.Equal(r, expect, result) - }) - }) - - t.Run("redis", func(t *testing.T) { - retry.Run(t, func(r *retry.R) { - // Request topology for redis - req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/redis?kind=", nil) - resp := httptest.NewRecorder() - obj, err := a.srv.UIServiceTopology(resp, req) - assert.Nil(r, err) - require.NoError(r, checkIndex(resp)) - - expect := ServiceTopology{ - Protocol: "http", - Upstreams: []*ServiceTopologySummary{}, + }, + }, + { + name: "redis", + httpReq: func() *http.Request { + req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/redis?kind=", nil) + return req + }(), + want: &ServiceTopology{ + Protocol: "http", + TransparentProxy: false, + Upstreams: []*ServiceTopologySummary{}, Downstreams: []*ServiceTopologySummary{ { ServiceSummary: ServiceSummary{ @@ -1642,18 +1617,327 @@ func TestUIServiceTopology(t *testing.T) { }, }, FilteredByACLs: false, - } - result := obj.(ServiceTopology) + }, + }, + } - // Internal accounting that is not returned in JSON response - for _, d := range result.Downstreams { - d.transparentProxySet = false - d.externalSourceSet = nil - d.checks = nil - } - require.Equal(r, expect, result) + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + run(t, tc) }) - }) + } +} + +// clearUnexportedFields sets unexported members of ServiceTopology to their +// type defaults, since the fields are not marshalled in the JSON response. +func clearUnexportedFields(result ServiceTopology) { + for _, u := range result.Upstreams { + u.transparentProxySet = false + u.externalSourceSet = nil + u.checks = nil + } + for _, d := range result.Downstreams { + d.transparentProxySet = false + d.externalSourceSet = nil + d.checks = nil + } +} + +func TestUIServiceTopology_RoutingConfigs(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + a := NewTestAgent(t, "") + defer a.Shutdown() + + // Register dashboard -> routing-config -> { counting, counting-v2 } + { + registrations := map[string]*structs.RegisterRequest{ + "Service dashboard": { + Datacenter: "dc1", + Node: "foo", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindTypical, + ID: "dashboard", + Service: "dashboard", + Port: 9002, + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "foo", + CheckID: "foo:dashboard", + Status: api.HealthPassing, + ServiceID: "dashboard", + ServiceName: "dashboard", + }, + }, + }, + "Service dashboard-proxy": { + Datacenter: "dc1", + Node: "foo", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindConnectProxy, + ID: "dashboard-sidecar-proxy", + Service: "dashboard-sidecar-proxy", + Port: 5000, + Address: "198.18.1.0", + Proxy: structs.ConnectProxyConfig{ + DestinationServiceName: "dashboard", + DestinationServiceID: "dashboard", + LocalServiceAddress: "127.0.0.1", + LocalServicePort: 9002, + Upstreams: []structs.Upstream{ + { + DestinationType: "service", + DestinationName: "routing-config", + LocalBindPort: 5000, + }, + }, + }, + LocallyRegisteredAsSidecar: true, + }, + }, + "Service counting": { + Datacenter: "dc1", + Node: "foo", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindTypical, + ID: "counting", + Service: "counting", + Port: 9003, + Address: "198.18.1.1", + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "foo", + CheckID: "foo:api", + Status: api.HealthPassing, + ServiceID: "counting", + ServiceName: "counting", + }, + }, + }, + "Service counting-proxy": { + Datacenter: "dc1", + Node: "foo", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindConnectProxy, + ID: "counting-proxy", + Service: "counting-proxy", + Port: 5001, + Address: "198.18.1.1", + Proxy: structs.ConnectProxyConfig{ + DestinationServiceName: "counting", + }, + LocallyRegisteredAsSidecar: true, + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "foo", + CheckID: "foo:counting-proxy", + Status: api.HealthPassing, + ServiceID: "counting-proxy", + ServiceName: "counting-proxy", + }, + }, + }, + "Service counting-v2": { + Datacenter: "dc1", + Node: "foo", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindTypical, + ID: "counting-v2", + Service: "counting-v2", + Port: 9004, + Address: "198.18.1.2", + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "foo", + CheckID: "foo:api", + Status: api.HealthPassing, + ServiceID: "counting-v2", + ServiceName: "counting-v2", + }, + }, + }, + "Service counting-v2-proxy": { + Datacenter: "dc1", + Node: "foo", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindConnectProxy, + ID: "counting-v2-proxy", + Service: "counting-v2-proxy", + Port: 5002, + Address: "198.18.1.2", + Proxy: structs.ConnectProxyConfig{ + DestinationServiceName: "counting-v2", + }, + LocallyRegisteredAsSidecar: true, + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "foo", + CheckID: "foo:counting-v2-proxy", + Status: api.HealthPassing, + ServiceID: "counting-v2-proxy", + ServiceName: "counting-v2-proxy", + }, + }, + }, + } + for _, args := range registrations { + var out struct{} + require.NoError(t, a.RPC("Catalog.Register", args, &out)) + } + } + { + entries := []structs.ConfigEntryRequest{ + { + Datacenter: "dc1", + Entry: &structs.ProxyConfigEntry{ + Kind: structs.ProxyDefaults, + Name: structs.ProxyConfigGlobal, + Config: map[string]interface{}{ + "protocol": "http", + }, + }, + }, + { + Datacenter: "dc1", + Entry: &structs.ServiceRouterConfigEntry{ + Kind: structs.ServiceRouter, + Name: "routing-config", + Routes: []structs.ServiceRoute{ + { + Match: &structs.ServiceRouteMatch{ + HTTP: &structs.ServiceRouteHTTPMatch{ + PathPrefix: "/v2", + }, + }, + Destination: &structs.ServiceRouteDestination{ + Service: "counting-v2", + }, + }, + { + Match: &structs.ServiceRouteMatch{ + HTTP: &structs.ServiceRouteHTTPMatch{ + PathPrefix: "/", + }, + }, + Destination: &structs.ServiceRouteDestination{ + Service: "counting", + }, + }, + }, + }, + }, + } + for _, req := range entries { + out := false + require.NoError(t, a.RPC("ConfigEntry.Apply", &req, &out)) + } + } + + type testCase struct { + name string + httpReq *http.Request + want *ServiceTopology + wantErr string + } + + run := func(t *testing.T, tc testCase) { + retry.Run(t, func(r *retry.R) { + resp := httptest.NewRecorder() + obj, err := a.srv.UIServiceTopology(resp, tc.httpReq) + assert.Nil(r, err) + + if tc.wantErr != "" { + assert.Nil(r, tc.want) // should not define a non-nil want + require.Equal(r, tc.wantErr, resp.Body.String()) + require.Nil(r, obj) + return + } + + require.NoError(r, checkIndex(resp)) + require.NotNil(r, obj) + result := obj.(ServiceTopology) + clearUnexportedFields(result) + + require.Equal(r, *tc.want, result) + }) + } + + tcs := []testCase{ + { + name: "dashboard has upstream routing-config", + httpReq: func() *http.Request { + req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/dashboard?kind=", nil) + return req + }(), + want: &ServiceTopology{ + Protocol: "http", + Downstreams: []*ServiceTopologySummary{}, + Upstreams: []*ServiceTopologySummary{ + { + ServiceSummary: ServiceSummary{ + Name: "routing-config", + Datacenter: "dc1", + EnterpriseMeta: *structs.DefaultEnterpriseMetaInDefaultPartition(), + TransparentProxy: false, + }, + Intention: structs.IntentionDecisionSummary{ + DefaultAllow: true, + Allowed: true, + }, + Source: structs.TopologySourceRoutingConfig, + }, + }, + }, + }, + { + name: "counting has downstream dashboard", + httpReq: func() *http.Request { + req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/counting?kind=", nil) + return req + }(), + want: &ServiceTopology{ + Protocol: "http", + Upstreams: []*ServiceTopologySummary{}, + Downstreams: []*ServiceTopologySummary{ + { + ServiceSummary: ServiceSummary{ + Name: "dashboard", + Datacenter: "dc1", + Nodes: []string{"foo"}, + InstanceCount: 1, + ChecksPassing: 1, + }, + Source: "proxy-registration", + Intention: structs.IntentionDecisionSummary{ + Allowed: true, + DefaultAllow: true, + }, + }, + }, + }, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + run(t, tc) + }) + } } func TestUIEndpoint_MetricsProxy(t *testing.T) {