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 }