github.com/kiali/kiali@v1.84.0/graph/telemetry/istio/appender/service_entry.go (about)

     1  package appender
     2  
     3  import (
     4  	"context"
     5  	"strings"
     6  
     7  	"istio.io/client-go/pkg/apis/networking/v1beta1"
     8  
     9  	"github.com/kiali/kiali/business"
    10  	"github.com/kiali/kiali/config"
    11  	"github.com/kiali/kiali/graph"
    12  	"github.com/kiali/kiali/log"
    13  )
    14  
    15  const ServiceEntryAppenderName = "serviceEntry"
    16  
    17  // ServiceEntryAppender is responsible for identifying service nodes that are defined in Istio as
    18  // a serviceEntry. A single serviceEntry can define multiple hosts and as such multiple service nodes may
    19  // map to different hosts of a single serviceEntry. We'll call these "se-service" nodes.  The appender
    20  // handles this in the following way:
    21  //
    22  //		For Each "se-service" node
    23  //		   1. if necessary, create a "service-entry" node to aggregate this, and possibly other, "se-service" nodes
    24  //		     -- a "service-entry" node is a service node-type with isServiceEntry set in the metadata
    25  //		     -- a "service-entry" is namespace-specific; An Istio service entry is defined in a particular
    26  //	         namespace, but it can be "exported" to many (all namespaces by default).  So, think as if the
    27  //	         service entry definition is duplicated in each exported namespace, and therefore you can get a
    28  //	         "service-entry" node in each.
    29  //		   2. aggregate the "se-service" node into the appropriate, new or existing, "service-entry" node
    30  //		     -- incoming edges
    31  //		     -- outgoing edges (unusual but can have outgoing edge to egress gateway)
    32  //		     -- per-host traffic (in the metadata)
    33  //		   3. remove the "se-service" node from the trafficMap
    34  //		   4. add any new "service-entry" node to the trafficMap
    35  //
    36  // Doc Links
    37  // - https://istio.io/docs/reference/config/networking/v1alpha3/service-entry/#ServiceEntry
    38  // - https://istio.io/docs/examples/advanced-gateways/wildcard-egress-hosts/
    39  //
    40  // A note about wildcard hosts. External service entries allow for prefix wildcarding such that
    41  // many different service requests may be handled by the same service entry definition.  For example,
    42  // host = *.wikipedia.com would match requests for en.wikipedia.com and de.wikipedia.com. The Istio
    43  // telemetry produces only one "se-service" node with the wilcard host as the destination_service_name.
    44  type ServiceEntryAppender struct {
    45  	AccessibleNamespaces graph.AccessibleNamespaces
    46  	GraphType            string // This appender does not operate on service graphs because it adds workload nodes.
    47  }
    48  
    49  // Name implements Appender
    50  func (a ServiceEntryAppender) Name() string {
    51  	return ServiceEntryAppenderName
    52  }
    53  
    54  // IsFinalizer implements Appender
    55  func (a ServiceEntryAppender) IsFinalizer() bool {
    56  	return false
    57  }
    58  
    59  // AppendGraph implements Appender
    60  func (a ServiceEntryAppender) AppendGraph(trafficMap graph.TrafficMap, globalInfo *graph.AppenderGlobalInfo, namespaceInfo *graph.AppenderNamespaceInfo) {
    61  	if len(trafficMap) == 0 {
    62  		return
    63  	}
    64  
    65  	// First, identify the candidate "se-service" nodes (i.e. the service nodes that are candidates for conversion to a "service-entry" node)
    66  	candidates := make(map[string]*graph.Node)
    67  	for _, n := range trafficMap {
    68  		// a non-injected service node may represent a mesh_internal ServiceEntry, if it has cluster and namespace set
    69  		if n.NodeType == graph.NodeTypeService {
    70  			isInjected := n.Metadata[graph.IsInjected] == true
    71  			if !isInjected && graph.IsOK(n.Cluster) && graph.IsOK(n.Namespace) {
    72  				candidates[n.ID] = n
    73  			}
    74  			continue
    75  		}
    76  
    77  		// for non-service nodes, look for edges to non-injected service nodes that may represent a mesh_external ServicEntry.
    78  		// It probably will not have cluster and namespace set, either way, we need to get this information from the source node
    79  		// because it is the requesting service that needs access to the ServiceEntry.
    80  		for _, e := range n.Edges {
    81  			isService := e.Dest.NodeType == graph.NodeTypeService
    82  			isInjected := e.Dest.Metadata[graph.IsInjected] == true
    83  			if isService && !isInjected {
    84  				candidates[n.ID] = n
    85  				break
    86  			}
    87  		}
    88  	}
    89  	// If there are no "se-service" node candidates then we can return immediately.
    90  	if len(candidates) == 0 {
    91  		return
    92  	}
    93  
    94  	// Otherwise, if there are SE hosts defined for the cluster:namespace, check to see if they apply to the node
    95  	nodesToCheck := []*graph.Node{}
    96  	for _, n := range candidates {
    97  		if a.loadServiceEntryHosts(n.Cluster, n.Namespace, globalInfo) {
    98  			nodesToCheck = append(nodesToCheck, n)
    99  		}
   100  	}
   101  
   102  	if len(nodesToCheck) > 0 {
   103  		a.applyServiceEntries(trafficMap, nodesToCheck, globalInfo, namespaceInfo)
   104  	}
   105  }
   106  
   107  // loadServiceEntryHosts loads serviceEntry hosts for the provided cluster and namespace. Returns true if any are found, otherwise false.
   108  func (a ServiceEntryAppender) loadServiceEntryHosts(cluster, namespace string, globalInfo *graph.AppenderGlobalInfo) bool {
   109  	// get the cached hosts for this cluster:namespace, otherwise add to the cache
   110  	serviceEntryHosts, found := getServiceEntryHosts(cluster, namespace, globalInfo)
   111  	if !found {
   112  		istioCfg, err := globalInfo.Business.IstioConfig.GetIstioConfigList(context.TODO(), cluster, business.IstioConfigCriteria{
   113  			IncludeServiceEntries: true,
   114  		})
   115  		graph.CheckError(err)
   116  
   117  		// ... and then use ExportTo to decide whether the hosts are accessible to the namespace
   118  		for _, entry := range istioCfg.ServiceEntries {
   119  			if entry.Spec.Hosts != nil && isExportedToNamespace(entry, namespace) {
   120  				location := "MESH_EXTERNAL"
   121  				if entry.Spec.Location.String() == "MESH_INTERNAL" {
   122  					location = "MESH_INTERNAL"
   123  				}
   124  				se := serviceEntry{
   125  					cluster:   cluster,
   126  					exportTo:  entry.Spec.ExportTo,
   127  					location:  location,
   128  					name:      entry.Name,
   129  					namespace: namespace,
   130  				}
   131  				for _, host := range entry.Spec.Hosts {
   132  					serviceEntryHosts.addHost(host, &se)
   133  				}
   134  			}
   135  		}
   136  	}
   137  	return len(serviceEntryHosts) > 0
   138  }
   139  
   140  func (a ServiceEntryAppender) applyServiceEntries(trafficMap graph.TrafficMap, nodesToCheck []*graph.Node, globalInfo *graph.AppenderGlobalInfo, namespaceInfo *graph.AppenderNamespaceInfo) {
   141  	// a map of "se-service" nodes to the "service-entry" information
   142  	seMap := make(map[*serviceEntry][]*graph.Node)
   143  
   144  	for _, n := range nodesToCheck {
   145  		if n.NodeType == graph.NodeTypeService {
   146  			// Must be a non-egress(PassthroughCluster or BlackHoleCluster) service node
   147  			candidate := n
   148  			isEgressCluster := candidate.Metadata[graph.IsEgressCluster] == true
   149  			if candidate.NodeType == graph.NodeTypeService && !isEgressCluster {
   150  				// To match, a service entry must be exported to the source namespace, and the requested
   151  				// service must match a defined host.  Note that the source namespace is assumed to be the
   152  				// the same as the appender namespace as all requests for the service entry should be coming
   153  				// from workloads in the current namespace being processed for the graph.
   154  				if se, ok := a.getServiceEntry(n.Cluster, n.Namespace, candidate.Service, globalInfo); ok {
   155  					if nodes, ok := seMap[se]; ok {
   156  						seMap[se] = append(nodes, candidate)
   157  					} else {
   158  						seMap[se] = []*graph.Node{candidate}
   159  					}
   160  				}
   161  			}
   162  			continue
   163  		}
   164  
   165  		for _, e := range n.Edges {
   166  			// Must be a non-egress(PassthroughCluster or BlackHoleCluster) service node
   167  			candidate := e.Dest
   168  			isEgressCluster := candidate.Metadata[graph.IsEgressCluster] == true
   169  			if candidate.NodeType == graph.NodeTypeService && !isEgressCluster {
   170  				// Same matching rules as above
   171  				if se, ok := a.getServiceEntry(n.Cluster, n.Namespace, candidate.Service, globalInfo); ok {
   172  					if nodes, ok := seMap[se]; ok {
   173  						seMap[se] = append(nodes, candidate)
   174  					} else {
   175  						seMap[se] = []*graph.Node{candidate}
   176  					}
   177  				}
   178  			}
   179  		}
   180  	}
   181  
   182  	// Replace "se-service" nodes with a "service-entry" node
   183  	for se, seServiceNodes := range seMap {
   184  		serviceEntryNode, err := graph.NewNode(se.cluster, namespaceInfo.Namespace, se.name, "", "", "", "", a.GraphType)
   185  		if err != nil {
   186  			log.Warningf("Skipping serviceEntryNode, %s", err)
   187  			continue
   188  		}
   189  		serviceEntryNode.Metadata[graph.IsServiceEntry] = &graph.SEInfo{
   190  			Hosts:     se.hosts,
   191  			Location:  se.location,
   192  			Namespace: se.namespace,
   193  		}
   194  		serviceEntryNode.Metadata[graph.DestServices] = graph.NewDestServicesMetadata()
   195  		for _, doomedSeServiceNode := range seServiceNodes {
   196  			// aggregate node traffic
   197  			graph.AggregateNodeTraffic(doomedSeServiceNode, serviceEntryNode)
   198  			// aggregate node dest-services to capture all of the distinct requested services
   199  			if destServices, ok := doomedSeServiceNode.Metadata[graph.DestServices]; ok {
   200  				for k, v := range destServices.(graph.DestServicesMetadata) {
   201  					serviceEntryNode.Metadata[graph.DestServices].(graph.DestServicesMetadata)[k] = v
   202  				}
   203  			}
   204  			// redirect edges leading to the doomed se-service node to the new aggregate
   205  			for _, n := range trafficMap {
   206  				for _, edge := range n.Edges {
   207  					if edge.Dest.ID == doomedSeServiceNode.ID {
   208  						edge.Dest = serviceEntryNode
   209  					}
   210  				}
   211  
   212  				// If there is more than one doomed node, edges leading to the new aggregated node must
   213  				// also be aggregated per source and protocol.
   214  				if len(seServiceNodes) > 1 {
   215  					aggregateEdges(n, serviceEntryNode)
   216  				}
   217  			}
   218  			// redirect/aggregate edges leading from the doomed se-service node [to an egress gateway]
   219  			for _, doomedEdge := range doomedSeServiceNode.Edges {
   220  				var aggregateEdge *graph.Edge
   221  				for _, e := range serviceEntryNode.Edges {
   222  					if doomedEdge.Dest.ID == e.Dest.ID && doomedEdge.Metadata[graph.ProtocolKey] == e.Metadata[graph.ProtocolKey] {
   223  						aggregateEdge = e
   224  						break
   225  					}
   226  				}
   227  				if nil == aggregateEdge {
   228  					aggregateEdge = serviceEntryNode.AddEdge(doomedEdge.Dest)
   229  					aggregateEdge.Metadata[graph.ProtocolKey] = doomedEdge.Metadata[graph.ProtocolKey]
   230  				}
   231  				graph.AggregateEdgeTraffic(doomedEdge, aggregateEdge)
   232  			}
   233  			delete(trafficMap, doomedSeServiceNode.ID)
   234  		}
   235  		trafficMap[serviceEntryNode.ID] = serviceEntryNode
   236  	}
   237  }
   238  
   239  // TODO: I don't know what happens (nothing good) if a ServiceEntry is defined in an inaccessible namespace
   240  // but exported to all namespaces (exportTo: *). It's possible that would allow traffic to flow from an
   241  // accessible workload through a serviceEntry whose definition we can't fetch.
   242  func (a ServiceEntryAppender) getServiceEntry(cluster, namespace, serviceName string, globalInfo *graph.AppenderGlobalInfo) (*serviceEntry, bool) {
   243  	serviceEntryHosts, _ := getServiceEntryHosts(cluster, namespace, globalInfo)
   244  
   245  	for host, serviceEntriesForHost := range serviceEntryHosts {
   246  		for _, se := range serviceEntriesForHost {
   247  			// handle exact match
   248  			// note: this also handles wildcard-prefix cases because the destination_service_name set by istio
   249  			// is the matching host (e.g. *.wikipedia.com), not the rested service (e.g. de.wikipedia.com)
   250  			if host == serviceName {
   251  				return se, true
   252  			}
   253  			// handle serviceName prefix (e.g. host = serviceName.namespace.svc.cluster.local)
   254  			if se.location == "MESH_INTERNAL" {
   255  				hostSplitted := strings.Split(host, ".")
   256  
   257  				if len(hostSplitted) == 3 && hostSplitted[2] == config.IstioMultiClusterHostSuffix {
   258  					// If suffix is "global", this node should be a service entry
   259  					// related to multi-cluster configs. Only exact match should be done, so
   260  					// skip prefix matching.
   261  					//
   262  					// Number of entries == 3 in the host is checked because the host
   263  					// must be of the form svc.namespace.global for Istio to
   264  					// work correctly in the multi-cluster/multiple-control-plane scenario.
   265  					continue
   266  				} else if hostSplitted[0] == serviceName {
   267  					return se, true
   268  				}
   269  			}
   270  		}
   271  	}
   272  
   273  	return nil, false
   274  }
   275  
   276  func isExportedToNamespace(se *v1beta1.ServiceEntry, namespace string) bool {
   277  	if se.Spec.ExportTo == nil {
   278  		return true
   279  	}
   280  	for _, export := range se.Spec.ExportTo {
   281  		if export == "*" {
   282  			return true
   283  		}
   284  		if export == "." && se.Namespace == namespace {
   285  			return true
   286  		}
   287  		if export == se.Namespace {
   288  			return true
   289  		}
   290  	}
   291  
   292  	return false
   293  }
   294  
   295  // aggregateEdges identifies edges that are going from <node> to <serviceEntryNode> and
   296  // aggregates them in only one edge per protocol. This ensures that the traffic map
   297  // will comply with the assumption/rule of one edge per protocol between any two nodes.
   298  func aggregateEdges(node *graph.Node, serviceEntryNode *graph.Node) {
   299  	edgesToAggregate := make(map[string][]*graph.Edge)
   300  	bound := 0
   301  	for _, edge := range node.Edges {
   302  		if edge.Dest == serviceEntryNode {
   303  			protocol := edge.Metadata[graph.ProtocolKey].(string)
   304  			edgesToAggregate[protocol] = append(edgesToAggregate[protocol], edge)
   305  		} else {
   306  			// Manipulating the slice as in this StackOverflow post: https://stackoverflow.com/a/20551116
   307  			node.Edges[bound] = edge
   308  			bound++
   309  		}
   310  	}
   311  	node.Edges = node.Edges[:bound]
   312  	// Add aggregated edge
   313  	for protocol, edges := range edgesToAggregate {
   314  		aggregatedEdge := node.AddEdge(serviceEntryNode)
   315  		aggregatedEdge.Metadata[graph.ProtocolKey] = protocol
   316  		for _, e := range edges {
   317  			graph.AggregateEdgeTraffic(e, aggregatedEdge)
   318  		}
   319  	}
   320  }