api: expose upstream routing configurations in topology view (#10811)

Some users are defining routing configurations that do not have associated services. This commit surfaces these configs in the topology visualization. Also fixes a minor internal bug with non-transparent proxy upstream/downstream references.
This commit is contained in:
Chris S. Kim 2021-08-25 15:20:32 -04:00 committed by GitHub
parent a6d22efb49
commit 45dcc8b553
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 769 additions and 202 deletions

View File

@ -7,14 +7,15 @@ import (
"net/rpc" "net/rpc"
"testing" "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" msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc"
"github.com/hashicorp/raft" "github.com/hashicorp/raft"
"github.com/hashicorp/serf/serf" "github.com/hashicorp/serf/serf"
"github.com/stretchr/testify/require" "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 { 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) { func registerIntentionUpstreamEntries(t *testing.T, codec rpc.ClientCodec, token string) {
t.Helper() t.Helper()

View File

@ -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) { func TestInternal_ServiceTopology_ACL(t *testing.T) {
if testing.Short() { if testing.Short() {
t.Skip("too slow for testing.Short") t.Skip("too slow for testing.Short")

View File

@ -3092,44 +3092,67 @@ func (s *Store) ServiceTopology(
maxIdx = idx maxIdx = idx
} }
var ( var upstreamSources = make(map[string]string)
seenUpstreams = make(map[string]struct{})
upstreamSources = make(map[string]string)
)
for _, un := range upstreamNames { for _, un := range upstreamNames {
if _, ok := seenUpstreams[un.String()]; !ok {
seenUpstreams[un.String()] = struct{}{}
}
upstreamSources[un.String()] = structs.TopologySourceRegistration 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) 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 // Only transparent proxies have upstreams from intentions
switch { if hasTransparent {
case svc.Decision.HasExact: idx, intentionUpstreams, err := s.intentionTopologyTxn(tx, ws, sn, false, defaultAllow)
source = structs.TopologySourceSpecificIntention if err != nil {
case svc.Decision.DefaultAllow: return 0, nil, err
source = structs.TopologySourceDefaultAllow
default:
source = structs.TopologySourceWildcardIntention
} }
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) idx, unfilteredUpstreams, err := s.combinedServiceNodesTxn(tx, ws, upstreamNames)
@ -3154,28 +3177,29 @@ func (s *Store) ServiceTopology(
upstreams = append(upstreams, upstream) upstreams = append(upstreams, upstream)
} }
matchEntry := structs.IntentionMatchEntry{ var foundUpstreams = make(map[structs.ServiceName]struct{})
Namespace: entMeta.NamespaceOrDefault(), for _, csn := range upstreams {
Name: service, foundUpstreams[csn.Service.CompoundServiceName()] = struct{}{}
} }
_, srcIntentions, err := compatIntentionMatchOneTxn(
tx,
ws,
matchEntry,
// The given service is a source relative to its upstreams // Check upstream names that had no service instances to see if they are routing config.
structs.IntentionMatchSource,
)
if err != nil {
return 0, nil, fmt.Errorf("failed to query intentions for %s", sn.String())
}
for _, un := range upstreamNames { for _, un := range upstreamNames {
decision, err := s.IntentionDecision(un.Name, un.NamespaceOrDefault(), srcIntentions, structs.IntentionMatchDestination, defaultAllow, false) if _, ok := foundUpstreams[un]; ok {
if err != nil { continue
return 0, nil, fmt.Errorf("failed to get intention decision from (%s) to (%s): %v", }
sn.String(), un.String(), err)
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) idx, downstreamNames, err := s.downstreamsForServiceTxn(tx, ws, dc, sn)
@ -3186,14 +3210,8 @@ func (s *Store) ServiceTopology(
maxIdx = idx maxIdx = idx
} }
var ( var downstreamSources = make(map[string]string)
seenDownstreams = make(map[string]struct{})
downstreamSources = make(map[string]string)
)
for _, dn := range downstreamNames { for _, dn := range downstreamNames {
if _, ok := seenDownstreams[dn.String()]; !ok {
seenDownstreams[dn.String()] = struct{}{}
}
downstreamSources[dn.String()] = structs.TopologySourceRegistration downstreamSources[dn.String()] = structs.TopologySourceRegistration
} }
@ -3207,7 +3225,7 @@ func (s *Store) ServiceTopology(
downstreamDecisions := make(map[string]structs.IntentionDecisionSummary) downstreamDecisions := make(map[string]structs.IntentionDecisionSummary)
for _, svc := range intentionDownstreams { for _, svc := range intentionDownstreams {
if _, ok := seenDownstreams[svc.Name.String()]; ok { if _, ok := downstreamSources[svc.Name.String()]; ok {
// Avoid duplicating entry // Avoid duplicating entry
continue continue
} }
@ -3226,6 +3244,26 @@ func (s *Store) ServiceTopology(
downstreamSources[svc.Name.String()] = source 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) idx, unfilteredDownstreams, err := s.combinedServiceNodesTxn(tx, ws, downstreamNames)
if err != nil { if err != nil {
return 0, nil, fmt.Errorf("failed to get downstreams for %q: %v", sn.String(), err) 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) sn = structs.NewServiceName(downstream.Service.Proxy.DestinationServiceName, &downstream.Service.EnterpriseMeta)
} }
if _, ok := tproxyMap[sn]; !ok && downstreamSources[sn.String()] != structs.TopologySourceRegistration { 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 continue
} }
downstreams = append(downstreams, downstream) 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{ resp := &structs.ServiceTopology{
TransparentProxy: fullyTransparent, TransparentProxy: fullyTransparent,
MetricsProtocol: protocol, MetricsProtocol: protocol,

View File

@ -39,17 +39,21 @@ const (
const ( const (
// TODO (freddy) Should we have a TopologySourceMixed when there is a mix of proxy reg and tproxy? // 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. // 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" 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" 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" 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" 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 // MeshGatewayConfig controls how Mesh Gateways are configured and used

View File

@ -343,6 +343,21 @@ RPC:
} }
upstreamResp = append(upstreamResp, &sum) 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) sortedDownstreams := prepSummaryOutput(downstreams, true)
for _, svc := range sortedDownstreams { for _, svc := range sortedDownstreams {

View File

@ -12,15 +12,16 @@ import (
"sync/atomic" "sync/atomic"
"testing" "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/config"
"github.com/hashicorp/consul/agent/structs" "github.com/hashicorp/consul/agent/structs"
"github.com/hashicorp/consul/api" "github.com/hashicorp/consul/api"
"github.com/hashicorp/consul/sdk/testutil" "github.com/hashicorp/consul/sdk/testutil"
"github.com/hashicorp/consul/sdk/testutil/retry" "github.com/hashicorp/consul/sdk/testutil/retry"
"github.com/hashicorp/consul/testrpc" "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) { func TestUiIndex(t *testing.T) {
@ -1397,34 +1398,59 @@ func TestUIServiceTopology(t *testing.T) {
} }
} }
t.Run("request without kind", func(t *testing.T) { type testCase struct {
req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/ingress", nil) name string
resp := httptest.NewRecorder() httpReq *http.Request
obj, err := a.srv.UIServiceTopology(resp, req) want *ServiceTopology
require.Nil(t, err) wantErr string
require.Nil(t, obj) }
require.Equal(t, "Missing service kind", resp.Body.String())
})
t.Run("request with unsupported kind", func(t *testing.T) { run := func(t *testing.T, tc testCase) {
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) {
retry.Run(t, func(r *retry.R) { 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() resp := httptest.NewRecorder()
obj, err := a.srv.UIServiceTopology(resp, req) obj, err := a.srv.UIServiceTopology(resp, tc.httpReq)
assert.Nil(r, err) 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", Protocol: "tcp",
TransparentProxy: false, TransparentProxy: false,
Upstreams: []*ServiceTopologySummary{ Upstreams: []*ServiceTopologySummary{
@ -1449,29 +1475,15 @@ func TestUIServiceTopology(t *testing.T) {
}, },
Downstreams: []*ServiceTopologySummary{}, Downstreams: []*ServiceTopologySummary{},
FilteredByACLs: false, FilteredByACLs: false,
} },
result := obj.(ServiceTopology) },
{
// Internal accounting that is not returned in JSON response name: "api",
for _, u := range result.Upstreams { httpReq: func() *http.Request {
u.externalSourceSet = nil req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/api?kind=", nil)
u.checks = nil return req
u.transparentProxySet = false }(),
} want: &ServiceTopology{
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{
Protocol: "tcp", Protocol: "tcp",
TransparentProxy: true, TransparentProxy: true,
Downstreams: []*ServiceTopologySummary{ Downstreams: []*ServiceTopologySummary{
@ -1518,34 +1530,15 @@ func TestUIServiceTopology(t *testing.T) {
}, },
}, },
FilteredByACLs: false, FilteredByACLs: false,
} },
result := obj.(ServiceTopology) },
{
// Internal accounting that is not returned in JSON response name: "web",
for _, u := range result.Upstreams { httpReq: func() *http.Request {
u.transparentProxySet = false req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/web?kind=", nil)
u.externalSourceSet = nil return req
u.checks = nil }(),
} want: &ServiceTopology{
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{
Protocol: "http", Protocol: "http",
TransparentProxy: false, TransparentProxy: false,
Upstreams: []*ServiceTopologySummary{ Upstreams: []*ServiceTopologySummary{
@ -1590,36 +1583,18 @@ func TestUIServiceTopology(t *testing.T) {
}, },
}, },
FilteredByACLs: false, FilteredByACLs: false,
} },
result := obj.(ServiceTopology) },
{
// Internal accounting that is not returned in JSON response name: "redis",
for _, u := range result.Upstreams { httpReq: func() *http.Request {
u.transparentProxySet = false req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/redis?kind=", nil)
u.externalSourceSet = nil return req
u.checks = nil }(),
} want: &ServiceTopology{
for _, d := range result.Downstreams { Protocol: "http",
d.transparentProxySet = false TransparentProxy: false,
d.externalSourceSet = nil Upstreams: []*ServiceTopologySummary{},
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{},
Downstreams: []*ServiceTopologySummary{ Downstreams: []*ServiceTopologySummary{
{ {
ServiceSummary: ServiceSummary{ ServiceSummary: ServiceSummary{
@ -1642,18 +1617,327 @@ func TestUIServiceTopology(t *testing.T) {
}, },
}, },
FilteredByACLs: false, FilteredByACLs: false,
} },
result := obj.(ServiceTopology) },
}
// Internal accounting that is not returned in JSON response for _, tc := range tcs {
for _, d := range result.Downstreams { t.Run(tc.name, func(t *testing.T) {
d.transparentProxySet = false run(t, tc)
d.externalSourceSet = nil
d.checks = nil
}
require.Equal(r, expect, result)
}) })
}) }
}
// 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) { func TestUIEndpoint_MetricsProxy(t *testing.T) {