From 7c26a71b4be6d6e480e34471453f2819eeb0d27d Mon Sep 17 00:00:00 2001 From: freddygv Date: Wed, 30 Sep 2020 08:23:19 -0600 Subject: [PATCH] Add topology HTTP endpoint --- agent/consul/state/config_entry.go | 2 +- agent/http_register.go | 1 + agent/ui_endpoint.go | 233 +++++++---- agent/ui_endpoint_test.go | 598 +++++++++++++++++++++++++---- 4 files changed, 681 insertions(+), 153 deletions(-) diff --git a/agent/consul/state/config_entry.go b/agent/consul/state/config_entry.go index 091fd19392..98c0e2430d 100644 --- a/agent/consul/state/config_entry.go +++ b/agent/consul/state/config_entry.go @@ -434,7 +434,7 @@ func (s *Store) discoveryChainSources(ws memdb.WatchSet, tx ReadTxn, dc string, // Only return the services that directly target the destination seenSource := make(map[structs.ServiceName]bool) - for sn, _ := range seenLink { + for sn := range seenLink { req := discoverychain.CompileRequest{ ServiceName: sn.Name, EvaluateInNamespace: sn.NamespaceOrDefault(), diff --git a/agent/http_register.go b/agent/http_register.go index 8326f9f966..4927c55dc2 100644 --- a/agent/http_register.go +++ b/agent/http_register.go @@ -99,6 +99,7 @@ func init() { registerEndpoint("/v1/internal/ui/services", []string{"GET"}, (*HTTPHandlers).UIServices) registerEndpoint("/v1/internal/ui/gateway-services-nodes/", []string{"GET"}, (*HTTPHandlers).UIGatewayServicesNodes) registerEndpoint("/v1/internal/ui/gateway-intentions/", []string{"GET"}, (*HTTPHandlers).UIGatewayIntentions) + registerEndpoint("/v1/internal/ui/service-topology/", []string{"GET"}, (*HTTPHandlers).UIServiceTopology) registerEndpoint("/v1/internal/acl/authorize", []string{"POST"}, (*HTTPHandlers).ACLAuthorize) registerEndpoint("/v1/kv/", []string{"GET", "PUT", "DELETE"}, (*HTTPHandlers).KVSEndpoint) registerEndpoint("/v1/operator/raft/configuration", []string{"GET"}, (*HTTPHandlers).OperatorRaftConfiguration) diff --git a/agent/ui_endpoint.go b/agent/ui_endpoint.go index 277a2b190e..8f141d85c0 100644 --- a/agent/ui_endpoint.go +++ b/agent/ui_endpoint.go @@ -16,30 +16,51 @@ import ( // to extract this. const metaExternalSource = "external-source" +// ServiceSummary is used to summarize a service +type ServiceSummary struct { + Kind structs.ServiceKind `json:",omitempty"` + Name string + Datacenter string + Tags []string + Nodes []string + ExternalSources []string + externalSourceSet map[string]struct{} // internal to track uniqueness + checks map[string]*structs.HealthCheck + InstanceCount int + ChecksPassing int + ChecksWarning int + ChecksCritical int + GatewayConfig GatewayConfig + + structs.EnterpriseMeta +} + +func (s *ServiceSummary) LessThan(other *ServiceSummary) bool { + if s.EnterpriseMeta.LessThan(&other.EnterpriseMeta) { + return true + } + return s.Name < other.Name +} + +type ServiceListingSummary struct { + ServiceSummary + + ConnectedWithProxy bool + ConnectedWithGateway bool +} + type GatewayConfig struct { AssociatedServiceCount int `json:",omitempty"` Addresses []string `json:",omitempty"` + // internal to track uniqueness addressesSet map[string]struct{} } -// ServiceSummary is used to summarize a service -type ServiceSummary struct { - Kind structs.ServiceKind `json:",omitempty"` - Name string - Tags []string - Nodes []string - InstanceCount int - ChecksPassing int - ChecksWarning int - ChecksCritical int - ExternalSources []string - externalSourceSet map[string]struct{} // internal to track uniqueness - GatewayConfig GatewayConfig `json:",omitempty"` - ConnectedWithProxy bool - ConnectedWithGateway bool - - structs.EnterpriseMeta +type ServiceTopology struct { + Upstreams []*ServiceSummary + Downstreams []*ServiceSummary + FilteredByACLs bool } // UINodes is used to list the nodes in a given datacenter. We return a @@ -163,9 +184,39 @@ RPC: return nil, err } - // Generate the summary - // TODO (gateways) (freddy) Have Internal.ServiceDump return ServiceDump instead. Need to add bexpr filtering for type. - return summarizeServices(out.Nodes.ToServiceDump(), out.Gateways, s.agent.config, args.Datacenter), nil + // Store the names of the gateways associated with each service + var ( + serviceGateways = make(map[structs.ServiceName][]structs.ServiceName) + numLinkedServices = make(map[structs.ServiceName]int) + ) + for _, gs := range out.Gateways { + serviceGateways[gs.Service] = append(serviceGateways[gs.Service], gs.Gateway) + numLinkedServices[gs.Gateway] += 1 + } + + summaries, hasProxy := summarizeServices(out.Nodes.ToServiceDump(), nil, "") + sorted := prepSummaryOutput(summaries, false) + + var result []*ServiceListingSummary + for _, svc := range sorted { + sum := ServiceListingSummary{ServiceSummary: *svc} + + sn := structs.NewServiceName(svc.Name, &svc.EnterpriseMeta) + if hasProxy[sn] { + sum.ConnectedWithProxy = true + } + + // Verify that at least one of the gateways linked by config entry has an instance registered in the catalog + for _, gw := range serviceGateways[sn] { + if s := summaries[gw]; s != nil && sum.InstanceCount > 0 { + sum.ConnectedWithGateway = true + } + } + sum.GatewayConfig.AssociatedServiceCount = numLinkedServices[sn] + + result = append(result, &sum) + } + return result, nil } // UIGatewayServices is used to query all the nodes for services associated with a gateway along with their gateway config @@ -200,17 +251,56 @@ RPC: return nil, err } - return summarizeServices(out.Dump, nil, s.agent.config, args.Datacenter), nil + summaries, _ := summarizeServices(out.Dump, s.agent.config, args.Datacenter) + return prepSummaryOutput(summaries, false), nil } -// TODO (freddy): Refactor to split up for the two use cases -func summarizeServices(dump structs.ServiceDump, gateways structs.GatewayServices, cfg *config.RuntimeConfig, dc string) []*ServiceSummary { - // Collect the summary information - var services []structs.ServiceName - summary := make(map[structs.ServiceName]*ServiceSummary) +func (s *HTTPHandlers) UIServiceTopology(resp http.ResponseWriter, req *http.Request) (interface{}, error) { + // Parse arguments + args := structs.ServiceSpecificRequest{} + if done := s.parse(resp, req, &args.Datacenter, &args.QueryOptions); done { + return nil, nil + } + if err := s.parseEntMeta(req, &args.EnterpriseMeta); err != nil { + return nil, err + } - linkedGateways := make(map[structs.ServiceName][]structs.ServiceName) - hasProxy := make(map[structs.ServiceName]bool) + args.ServiceName = strings.TrimPrefix(req.URL.Path, "/v1/internal/ui/service-topology/") + if args.ServiceName == "" { + resp.WriteHeader(http.StatusBadRequest) + fmt.Fprint(resp, "Missing service name") + return nil, nil + } + + // Make the RPC request + var out structs.IndexedServiceTopology + defer setMeta(resp, &out.QueryMeta) +RPC: + if err := s.agent.RPC("Internal.ServiceTopology", &args, &out); err != nil { + // Retry the request allowing stale data if no leader + if strings.Contains(err.Error(), structs.ErrNoLeader.Error()) && !args.AllowStale { + args.AllowStale = true + goto RPC + } + return nil, err + } + + upstreams, _ := summarizeServices(out.ServiceTopology.Upstreams.ToServiceDump(), nil, "") + downstreams, _ := summarizeServices(out.ServiceTopology.Downstreams.ToServiceDump(), nil, "") + + sum := ServiceTopology{ + Upstreams: prepSummaryOutput(upstreams, true), + Downstreams: prepSummaryOutput(downstreams, true), + FilteredByACLs: out.FilteredByACLs, + } + return sum, nil +} + +func summarizeServices(dump structs.ServiceDump, cfg *config.RuntimeConfig, dc string) (map[structs.ServiceName]*ServiceSummary, map[structs.ServiceName]bool) { + var ( + summary = make(map[structs.ServiceName]*ServiceSummary) + hasProxy = make(map[structs.ServiceName]bool) + ) getService := func(service structs.ServiceName) *ServiceSummary { serv, ok := summary[service] @@ -223,22 +313,12 @@ func summarizeServices(dump structs.ServiceDump, gateways structs.GatewayService InstanceCount: 0, } summary[service] = serv - services = append(services, service) } return serv } - // Collect the list of services linked to each gateway up front - // THis also allows tracking whether a service name is associated with a gateway - gsCount := make(map[structs.ServiceName]int) - - for _, gs := range gateways { - gsCount[gs.Gateway] += 1 - linkedGateways[gs.Service] = append(linkedGateways[gs.Service], gs.Gateway) - } - for _, csn := range dump { - if csn.GatewayService != nil { + if cfg != nil && csn.GatewayService != nil { gwsvc := csn.GatewayService sum := getService(gwsvc.Service) modifySummaryForGatewayService(cfg, dc, sum, gwsvc) @@ -248,15 +328,27 @@ func summarizeServices(dump structs.ServiceDump, gateways structs.GatewayService if csn.Service == nil { continue } - sid := structs.NewServiceName(csn.Service.Service, &csn.Service.EnterpriseMeta) - sum := getService(sid) + sn := structs.NewServiceName(csn.Service.Service, &csn.Service.EnterpriseMeta) + sum := getService(sn) svc := csn.Service sum.Nodes = append(sum.Nodes, csn.Node.Node) sum.Kind = svc.Kind + sum.Datacenter = csn.Node.Datacenter sum.InstanceCount += 1 if svc.Kind == structs.ServiceKindConnectProxy { - hasProxy[structs.NewServiceName(svc.Proxy.DestinationServiceName, &svc.EnterpriseMeta)] = true + sn := structs.NewServiceName(svc.Proxy.DestinationServiceName, &svc.EnterpriseMeta) + hasProxy[sn] = true + + destination := getService(sn) + for _, check := range csn.Checks { + cid := structs.NewCheckID(check.CheckID, &check.EnterpriseMeta) + uid := structs.UniqueID(csn.Node.Node, cid.String()) + if destination.checks == nil { + destination.checks = make(map[string]*structs.HealthCheck) + } + destination.checks[uid] = check + } } for _, tag := range svc.Tags { found := false @@ -266,7 +358,6 @@ func summarizeServices(dump structs.ServiceDump, gateways structs.GatewayService break } } - if !found { sum.Tags = append(sum.Tags, tag) } @@ -288,7 +379,28 @@ func summarizeServices(dump structs.ServiceDump, gateways structs.GatewayService } for _, check := range csn.Checks { - switch check.Status { + cid := structs.NewCheckID(check.CheckID, &check.EnterpriseMeta) + uid := structs.UniqueID(csn.Node.Node, cid.String()) + if sum.checks == nil { + sum.checks = make(map[string]*structs.HealthCheck) + } + sum.checks[uid] = check + } + } + + return summary, hasProxy +} + +func prepSummaryOutput(summaries map[structs.ServiceName]*ServiceSummary, excludeSidecars bool) []*ServiceSummary { + var resp []*ServiceSummary + + // Collect and sort resp for display + for _, sum := range summaries { + sort.Strings(sum.Nodes) + sort.Strings(sum.Tags) + + for _, chk := range sum.checks { + switch chk.Status { case api.HealthPassing: sum.ChecksPassing++ case api.HealthWarning: @@ -297,34 +409,15 @@ func summarizeServices(dump structs.ServiceDump, gateways structs.GatewayService sum.ChecksCritical++ } } - } - - // Return the services in sorted order - sort.Slice(services, func(i, j int) bool { - return services[i].LessThan(&services[j]) - }) - - output := make([]*ServiceSummary, len(summary)) - for idx, service := range services { - sum := summary[service] - if hasProxy[service] { - sum.ConnectedWithProxy = true - } - - // Verify that at least one of the gateways linked by config entry has an instance registered in the catalog - for _, gw := range linkedGateways[service] { - if s := summary[gw]; s != nil && s.InstanceCount > 0 { - sum.ConnectedWithGateway = true - } + if excludeSidecars && sum.Kind != structs.ServiceKindTypical { + continue } - sum.GatewayConfig.AssociatedServiceCount = gsCount[service] - - // Sort the nodes and tags - sort.Strings(sum.Nodes) - sort.Strings(sum.Tags) - output[idx] = sum + resp = append(resp, sum) } - return output + sort.Slice(resp, func(i, j int) bool { + return resp[i].LessThan(resp[j]) + }) + return resp } func modifySummaryForGatewayService( diff --git a/agent/ui_endpoint_test.go b/agent/ui_endpoint_test.go index 006aa841dd..2a55e6af53 100644 --- a/agent/ui_endpoint_test.go +++ b/agent/ui_endpoint_test.go @@ -223,6 +223,7 @@ func TestUiServices(t *testing.T) { Service: &structs.NodeService{ Kind: structs.ServiceKindTypical, Service: "api", + ID: "api-1", Tags: []string{"tag1", "tag2"}, }, Checks: structs.HealthChecks{ @@ -230,18 +231,20 @@ func TestUiServices(t *testing.T) { Node: "foo", Name: "api svc check", ServiceName: "api", + ServiceID: "api-1", Status: api.HealthWarning, }, }, }, - // register web svc on node foo + // register api-proxy svc on node foo { Datacenter: "dc1", Node: "foo", SkipNodeUpdate: true, Service: &structs.NodeService{ Kind: structs.ServiceKindConnectProxy, - Service: "web", + Service: "api-proxy", + ID: "api-proxy-1", Tags: []string{}, Meta: map[string]string{metaExternalSource: "k8s"}, Port: 1234, @@ -252,8 +255,9 @@ func TestUiServices(t *testing.T) { Checks: structs.HealthChecks{ &structs.HealthCheck{ Node: "foo", - Name: "web svc check", - ServiceName: "web", + Name: "api proxy listening", + ServiceName: "api-proxy", + ServiceID: "api-proxy-1", Status: api.HealthPassing, }, }, @@ -264,14 +268,12 @@ func TestUiServices(t *testing.T) { Node: "bar", Address: "127.0.0.2", Service: &structs.NodeService{ - Kind: structs.ServiceKindConnectProxy, + Kind: structs.ServiceKindTypical, Service: "web", + ID: "web-1", Tags: []string{}, Meta: map[string]string{metaExternalSource: "k8s"}, Port: 1234, - Proxy: structs.ConnectProxyConfig{ - DestinationServiceName: "api", - }, }, Checks: []*structs.HealthCheck{ { @@ -279,6 +281,7 @@ func TestUiServices(t *testing.T) { Name: "web svc check", Status: api.HealthCritical, ServiceName: "web", + ServiceID: "web-1", }, }, }, @@ -366,76 +369,107 @@ func TestUiServices(t *testing.T) { assertIndex(t, resp) // Should be 2 nodes, and all the empty lists should be non-nil - summary := obj.([]*ServiceSummary) - require.Len(t, summary, 5) + summary := obj.([]*ServiceListingSummary) + require.Len(t, summary, 6) // internal accounting that users don't see can be blown away for _, sum := range summary { sum.externalSourceSet = nil + sum.checks = nil } - expected := []*ServiceSummary{ + expected := []*ServiceListingSummary{ { - Kind: structs.ServiceKindTypical, - Name: "api", - Tags: []string{"tag1", "tag2"}, - Nodes: []string{"foo"}, - InstanceCount: 1, - ChecksPassing: 2, - ChecksWarning: 1, - ChecksCritical: 0, + ServiceSummary: ServiceSummary{ + Kind: structs.ServiceKindTypical, + Name: "api", + Datacenter: "dc1", + Tags: []string{"tag1", "tag2"}, + Nodes: []string{"foo"}, + InstanceCount: 1, + ChecksPassing: 2, + ChecksWarning: 1, + ChecksCritical: 0, + EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + }, ConnectedWithProxy: true, ConnectedWithGateway: true, - EnterpriseMeta: *structs.DefaultEnterpriseMeta(), }, { - Kind: structs.ServiceKindTypical, - Name: "cache", - Tags: nil, - Nodes: []string{"zip"}, - InstanceCount: 1, - ChecksPassing: 0, - ChecksWarning: 0, - ChecksCritical: 0, + ServiceSummary: ServiceSummary{ + Kind: structs.ServiceKindConnectProxy, + Name: "api-proxy", + Datacenter: "dc1", + Tags: nil, + Nodes: []string{"foo"}, + InstanceCount: 1, + ChecksPassing: 2, + ChecksWarning: 0, + ChecksCritical: 0, + ExternalSources: []string{"k8s"}, + EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + }, + }, + { + ServiceSummary: ServiceSummary{ + Kind: structs.ServiceKindTypical, + Name: "cache", + Datacenter: "dc1", + Tags: nil, + Nodes: []string{"zip"}, + InstanceCount: 1, + ChecksPassing: 0, + ChecksWarning: 0, + ChecksCritical: 0, + EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + }, ConnectedWithGateway: true, - EnterpriseMeta: *structs.DefaultEnterpriseMeta(), }, { - Kind: structs.ServiceKindConnectProxy, - Name: "web", - Tags: nil, - Nodes: []string{"bar", "foo"}, - InstanceCount: 2, - ChecksPassing: 2, - ChecksWarning: 1, - ChecksCritical: 1, - ExternalSources: []string{"k8s"}, - EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + ServiceSummary: ServiceSummary{ + Kind: structs.ServiceKindTypical, + Name: "consul", + Datacenter: "dc1", + Tags: nil, + Nodes: []string{a.Config.NodeName}, + InstanceCount: 1, + ChecksPassing: 1, + ChecksWarning: 0, + ChecksCritical: 0, + EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + }, }, { - Kind: structs.ServiceKindTypical, - Name: "consul", - Tags: nil, - Nodes: []string{a.Config.NodeName}, - InstanceCount: 1, - ChecksPassing: 1, - ChecksWarning: 0, - ChecksCritical: 0, - EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + ServiceSummary: ServiceSummary{ + Kind: structs.ServiceKindTerminatingGateway, + Name: "terminating-gateway", + Datacenter: "dc1", + Tags: nil, + Nodes: []string{"foo"}, + InstanceCount: 1, + ChecksPassing: 1, + ChecksWarning: 0, + ChecksCritical: 0, + GatewayConfig: GatewayConfig{AssociatedServiceCount: 2}, + EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + }, }, { - Kind: structs.ServiceKindTerminatingGateway, - Name: "terminating-gateway", - Tags: nil, - Nodes: []string{"foo"}, - InstanceCount: 1, - ChecksPassing: 2, - ChecksWarning: 1, - GatewayConfig: GatewayConfig{AssociatedServiceCount: 2}, - EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + ServiceSummary: ServiceSummary{ + Kind: structs.ServiceKindTypical, + Name: "web", + Datacenter: "dc1", + Tags: nil, + Nodes: []string{"bar"}, + InstanceCount: 1, + ChecksPassing: 0, + ChecksWarning: 0, + ChecksCritical: 1, + ExternalSources: []string{"k8s"}, + EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + }, }, } - require.ElementsMatch(t, expected, summary) }) @@ -448,39 +482,46 @@ func TestUiServices(t *testing.T) { assertIndex(t, resp) // Should be 2 nodes, and all the empty lists should be non-nil - summary := obj.([]*ServiceSummary) + summary := obj.([]*ServiceListingSummary) require.Len(t, summary, 2) // internal accounting that users don't see can be blown away for _, sum := range summary { sum.externalSourceSet = nil + sum.checks = nil } - expected := []*ServiceSummary{ + expected := []*ServiceListingSummary{ { - Kind: structs.ServiceKindTypical, - Name: "api", - Tags: []string{"tag1", "tag2"}, - Nodes: []string{"foo"}, - InstanceCount: 1, - ChecksPassing: 2, - ChecksWarning: 1, - ChecksCritical: 0, - ConnectedWithProxy: true, + ServiceSummary: ServiceSummary{ + Kind: structs.ServiceKindTypical, + Name: "api", + Datacenter: "dc1", + Tags: []string{"tag1", "tag2"}, + Nodes: []string{"foo"}, + InstanceCount: 1, + ChecksPassing: 1, + ChecksWarning: 1, + ChecksCritical: 0, + EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + }, + ConnectedWithProxy: false, ConnectedWithGateway: false, - EnterpriseMeta: *structs.DefaultEnterpriseMeta(), }, { - Kind: structs.ServiceKindConnectProxy, - Name: "web", - Tags: nil, - Nodes: []string{"bar", "foo"}, - InstanceCount: 2, - ChecksPassing: 2, - ChecksWarning: 1, - ChecksCritical: 1, - ExternalSources: []string{"k8s"}, - EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + ServiceSummary: ServiceSummary{ + Kind: structs.ServiceKindTypical, + Name: "web", + Datacenter: "dc1", + Tags: nil, + Nodes: []string{"bar"}, + InstanceCount: 1, + ChecksPassing: 0, + ChecksWarning: 0, + ChecksCritical: 1, + ExternalSources: []string{"k8s"}, + EnterpriseMeta: *structs.DefaultEnterpriseMeta(), + }, }, } require.ElementsMatch(t, expected, summary) @@ -582,7 +623,14 @@ func TestUIGatewayServiceNodes_Terminating(t *testing.T) { assert.Nil(t, err) assertIndex(t, resp) - dump := obj.([]*ServiceSummary) + summary := obj.([]*ServiceSummary) + + // internal accounting that users don't see can be blown away + for _, sum := range summary { + sum.externalSourceSet = nil + sum.checks = nil + } + expect := []*ServiceSummary{ { Name: "redis", @@ -590,6 +638,7 @@ func TestUIGatewayServiceNodes_Terminating(t *testing.T) { }, { Name: "db", + Datacenter: "dc1", Tags: []string{"backup", "primary"}, Nodes: []string{"bar", "baz"}, InstanceCount: 2, @@ -599,7 +648,7 @@ func TestUIGatewayServiceNodes_Terminating(t *testing.T) { EnterpriseMeta: *structs.DefaultEnterpriseMeta(), }, } - assert.ElementsMatch(t, expect, dump) + assert.ElementsMatch(t, expect, summary) } func TestUIGatewayServiceNodes_Ingress(t *testing.T) { @@ -748,6 +797,7 @@ func TestUIGatewayServiceNodes_Ingress(t *testing.T) { }, { Name: "db", + Datacenter: "dc1", Tags: []string{"backup", "primary"}, Nodes: []string{"bar", "baz"}, InstanceCount: 2, @@ -767,6 +817,7 @@ func TestUIGatewayServiceNodes_Ingress(t *testing.T) { // internal accounting that users don't see can be blown away for _, sum := range dump { sum.GatewayConfig.addressesSet = nil + sum.checks = nil } assert.ElementsMatch(t, expect, dump) } @@ -878,3 +929,386 @@ func TestUIEndpoint_modifySummaryForGatewayService_UseRequestedDCInsteadOfConfig expected := serviceCanonicalDNSName("test", "ingress", "dc2", "consul", nil) + ":42" require.Equal(t, expected, sum.GatewayConfig.Addresses[0]) } + +func TestUIServiceTopology(t *testing.T) { + t.Parallel() + + a := NewTestAgent(t, "") + defer a.Shutdown() + + // Register terminating gateway and config entry linking it to postgres + redis + { + registrations := map[string]*structs.RegisterRequest{ + "Node foo": { + Datacenter: "dc1", + Node: "foo", + Address: "127.0.0.2", + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "foo", + CheckID: "foo:alive", + Name: "foo-liveness", + Status: api.HealthPassing, + }, + }, + }, + "Service api on foo": { + Datacenter: "dc1", + Node: "foo", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindTypical, + ID: "api", + Service: "api", + Port: 9090, + Address: "198.18.1.2", + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "foo", + CheckID: "foo:api", + Name: "api-liveness", + Status: api.HealthPassing, + ServiceID: "api", + ServiceName: "api", + }, + }, + }, + "Service api-proxy": { + Datacenter: "dc1", + Node: "foo", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindConnectProxy, + ID: "api-proxy", + Service: "api-proxy", + Port: 8443, + Address: "198.18.1.2", + Proxy: structs.ConnectProxyConfig{ + DestinationServiceName: "api", + Upstreams: structs.Upstreams{ + { + DestinationName: "web", + LocalBindPort: 8080, + }, + }, + }, + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "foo", + CheckID: "foo:api-proxy", + Name: "api proxy listening", + Status: api.HealthPassing, + ServiceID: "api-proxy", + ServiceName: "api-proxy", + }, + }, + }, + "Node bar": { + Datacenter: "dc1", + Node: "bar", + Address: "127.0.0.3", + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "bar", + CheckID: "bar:alive", + Name: "bar-liveness", + Status: api.HealthPassing, + }, + }, + }, + "Service web on bar": { + Datacenter: "dc1", + Node: "bar", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindTypical, + ID: "web", + Service: "web", + Port: 80, + Address: "198.18.1.20", + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "bar", + CheckID: "bar:web", + Name: "web-liveness", + Status: api.HealthWarning, + ServiceID: "web", + ServiceName: "web", + }, + }, + }, + "Service web-proxy on bar": { + Datacenter: "dc1", + Node: "bar", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindConnectProxy, + ID: "web-proxy", + Service: "web-proxy", + Port: 8443, + Address: "198.18.1.20", + Proxy: structs.ConnectProxyConfig{ + DestinationServiceName: "web", + Upstreams: structs.Upstreams{ + { + DestinationName: "redis", + LocalBindPort: 123, + }, + }, + }, + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "bar", + CheckID: "bar:web-proxy", + Name: "web proxy listening", + Status: api.HealthCritical, + ServiceID: "web-proxy", + ServiceName: "web-proxy", + }, + }, + }, + "Node baz": { + Datacenter: "dc1", + Node: "baz", + Address: "127.0.0.4", + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "baz", + CheckID: "baz:alive", + Name: "baz-liveness", + Status: api.HealthPassing, + }, + }, + }, + "Service web on baz": { + Datacenter: "dc1", + Node: "baz", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindTypical, + ID: "web", + Service: "web", + Port: 80, + Address: "198.18.1.40", + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "baz", + CheckID: "baz:web", + Name: "web-liveness", + Status: api.HealthPassing, + ServiceID: "web", + ServiceName: "web", + }, + }, + }, + "Service web-proxy on baz": { + Datacenter: "dc1", + Node: "baz", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindConnectProxy, + ID: "web-proxy", + Service: "web-proxy", + Port: 8443, + Address: "198.18.1.40", + Proxy: structs.ConnectProxyConfig{ + DestinationServiceName: "web", + Upstreams: structs.Upstreams{ + { + DestinationName: "redis", + LocalBindPort: 123, + }, + }, + }, + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "baz", + CheckID: "baz:web-proxy", + Name: "web proxy listening", + Status: api.HealthCritical, + ServiceID: "web-proxy", + ServiceName: "web-proxy", + }, + }, + }, + "Node zip": { + Datacenter: "dc1", + Node: "zip", + Address: "127.0.0.5", + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "zip", + CheckID: "zip:alive", + Name: "zip-liveness", + Status: api.HealthPassing, + }, + }, + }, + "Service redis on zip": { + Datacenter: "dc1", + Node: "zip", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindTypical, + ID: "redis", + Service: "redis", + Port: 6379, + Address: "198.18.1.60", + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "zip", + CheckID: "zip:redis", + Name: "redis-liveness", + Status: api.HealthPassing, + ServiceID: "redis", + ServiceName: "redis", + }, + }, + }, + "Service redis-proxy on zip": { + Datacenter: "dc1", + Node: "zip", + SkipNodeUpdate: true, + Service: &structs.NodeService{ + Kind: structs.ServiceKindConnectProxy, + ID: "redis-proxy", + Service: "redis-proxy", + Port: 8443, + Address: "198.18.1.60", + Proxy: structs.ConnectProxyConfig{ + DestinationServiceName: "redis", + }, + }, + Checks: structs.HealthChecks{ + &structs.HealthCheck{ + Node: "zip", + CheckID: "zip:redis-proxy", + Name: "redis proxy listening", + Status: api.HealthCritical, + ServiceID: "redis-proxy", + ServiceName: "redis-proxy", + }, + }, + }, + } + for _, args := range registrations { + var out struct{} + require.NoError(t, a.RPC("Catalog.Register", args, &out)) + } + } + + t.Run("api", func(t *testing.T) { + // Request topology for api + req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/api", nil) + resp := httptest.NewRecorder() + obj, err := a.srv.UIServiceTopology(resp, req) + assert.Nil(t, err) + assertIndex(t, resp) + + expect := ServiceTopology{ + Upstreams: []*ServiceSummary{ + { + Name: "web", + Datacenter: "dc1", + Nodes: []string{"bar", "baz"}, + InstanceCount: 2, + ChecksPassing: 3, + ChecksWarning: 1, + ChecksCritical: 2, + }, + }, + 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 + } + require.Equal(t, expect, result) + }) + + t.Run("web", func(t *testing.T) { + // Request topology for web + req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/web", nil) + resp := httptest.NewRecorder() + obj, err := a.srv.UIServiceTopology(resp, req) + assert.Nil(t, err) + assertIndex(t, resp) + + expect := ServiceTopology{ + Upstreams: []*ServiceSummary{ + { + Name: "redis", + Datacenter: "dc1", + Nodes: []string{"zip"}, + InstanceCount: 1, + ChecksPassing: 2, + ChecksCritical: 1, + }, + }, + Downstreams: []*ServiceSummary{ + { + Name: "api", + Datacenter: "dc1", + Nodes: []string{"foo"}, + InstanceCount: 1, + ChecksPassing: 3, + }, + }, + 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 + } + for _, d := range result.Downstreams { + d.externalSourceSet = nil + d.checks = nil + } + require.Equal(t, expect, result) + }) + + t.Run("redis", func(t *testing.T) { + // Request topology for redis + req, _ := http.NewRequest("GET", "/v1/internal/ui/service-topology/redis", nil) + resp := httptest.NewRecorder() + obj, err := a.srv.UIServiceTopology(resp, req) + assert.Nil(t, err) + assertIndex(t, resp) + + expect := ServiceTopology{ + Downstreams: []*ServiceSummary{ + { + Name: "web", + Datacenter: "dc1", + Nodes: []string{"bar", "baz"}, + InstanceCount: 2, + ChecksPassing: 3, + ChecksWarning: 1, + ChecksCritical: 2, + }, + }, + FilteredByACLs: false, + } + result := obj.(ServiceTopology) + + // Internal accounting that is not returned in JSON response + for _, d := range result.Downstreams { + d.externalSourceSet = nil + d.checks = nil + } + require.Equal(t, expect, result) + }) +}