You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

526 lines
17 KiB

# Copyright (C) 2005-2025 Splunk Inc. All Rights Reserved.
import uuid
from collections import deque
MAX_SERVICES_SUPPORTED_IN_TREE_VIEW = 2000
def _generate_service_dependency_for_partial_backup(vertices):
'''
@param vertices: dict of service vertex object
@return: dict of service dependency relationship
{'service_id_1': {'_key': service_id_1, 'title': service_title_1, 'parents':['service_id_2', 'service_id_3']}
}
'''
output = {}
for v in list(vertices.values()):
data = {}
data['_key'] = v.id
data['title'] = v.title
# parse the relationship
data['parents'] = [i.id for i in v.parents]
output[v.id] = data
return output
def generate_subgraphs_json(data, service_id_filter=[], logger=None, partial_backup=False, max_depth_filter=-2,
severity_filter_ids=[]):
"""
Handles a request to create a service tree from the given data.
@type data: list
@param data: the raw service data
@type service_id_filter: list
@param service_id_filter: the list of filtered service ids
@type logger: object
@param logger: the logger to use
@type partial_backup: bool
@param partial_backup: if the subgraph is used by partial backup
@type max_depth_filter: integer
@param max_depth_filter: maximum depth value filter
@type severity_filter_ids: list
@param severity_filter_ids: the list of service ids from the severity filter
@rtype: list
@return: the list of subgraphs in the tree
"""
tree = ServiceTree(logger=logger)
vertices = tree.parse_services_to_vertices(data)
if partial_backup:
return _generate_service_dependency_for_partial_backup(vertices)
else:
filtered_vertices = tree.filter_vertices(vertices, service_id_filter, severity_filter_ids)
subgraphs = tree.create_subgraphs(filtered_vertices)
return tree.subgraphs_to_json(subgraphs, max_depth_filter)
class ServiceTree(object):
"""
Class to interact with service trees
"""
def __init__(self, logger=None):
"""
@type logger: object
@param logger: the logger to use
"""
self.logger = logger
def parse_services_to_vertices(self, services_data):
"""
Parse the raw services data, returning a lookup dict from service key
to a vertex representing the service in the tree.
@type services_data: dict
@param services_data: the raw services data
@rtype: dict
@return: dict from service key to its vertex in the tree
"""
if not services_data:
return {}
vertices = {}
for service_data in services_data:
service_id = service_data.get('_key', None)
if not service_id:
continue
vertex = vertices.setdefault(service_id, ServiceVertex(service_id))
vertex.title = service_data.get('title', '')
# Parse the service's dependent services (parents)
parent_ids = []
for parent in service_data.get('services_depends_on', []):
parent_id = parent.get('serviceid', None)
if not parent_id:
continue
parent_ids.append(parent_id)
# Set the vertex's parents
parents = {vertices.setdefault(parent_id, ServiceVertex(parent_id))
for parent_id in parent_ids}
vertex.parents.update(parents)
# Add this vertex to its parents as a child
for parent in parents:
parent.children.add(vertex)
vertices[service_id] = vertex
return vertices
def filter_vertices(self, vertices, vertex_ids, severity_filter_ids=[]):
"""
Filters the given list of vertices. If the list of vertex_ids is provided,
only vertices that are connected to a vertex in vertex_ids in a meaningful
way are included in the output list. If the list of severity ids is provided,
only vertices that are children of a vertex in this list will be included in the
output list. If both are provided, the intersection of the search results from each
list will be returned.
@type vertices: dict
@param vertices: the lookup dict from vertex id to service vertex
@type vertex_ids: list
@param vertices: the list of vertex ids from the service filter
@type severity_filter_ids: list
@param severity_filter_ids: the list of vertex ids that have a minimum severity
@rtype: dict
@return: dict from service key to its vertex in the tree
"""
if not vertex_ids and not severity_filter_ids:
return vertices
# Maintain separate structures for keeping track of which vertices we've visited
# from a particular direction (i.e. either through children or through parents)
children = set()
parents = set()
visited_by_service = set()
# Traverse the vertices so that only the vertices associated with the filter are visited
if vertex_ids:
for vertex_id in vertex_ids:
vertex = vertices.get(vertex_id, None)
if not vertex:
continue
vertex.traverse_children(children)
vertex.traverse_parents(parents)
visited_by_service.update(children)
visited_by_service.update(parents)
else:
visited_by_service = {vertices[v] for v in vertices}
children = set()
visited_by_severity = set()
# Traverse the vertices so that only the vertices that are children of ones from the severity filter are visited
if severity_filter_ids:
for vertex_id in severity_filter_ids:
vertex = vertices.get(vertex_id, None)
if not vertex:
continue
vertex.traverse_children(children)
visited_by_severity.update(children)
else:
visited_by_severity = {vertices[v] for v in vertices}
visited = visited_by_service.intersection(visited_by_severity)
# Update the vertices edge data with only the visited vertices
filtered = {}
for v in visited:
filtered[v.id] = v
v.children.intersection_update(visited)
v.parents.intersection_update(visited)
if self.logger is not None:
self.logger.info(
'filtered service_tree count=%s filtered_count=%s service_id_filter=%s severity_filter_ids=%s',
len(vertices), len(filtered), vertex_ids, severity_filter_ids
)
return filtered
def create_subgraphs(self, vertices):
"""
Groups connected vertices into a subgraph object.
@type vertices: dict
@param vertices: the lookup dict from vertex id to service vertex
@rtype: list
@return: list of subgraphs
"""
subgraphs = []
visited = set()
for vertex in list(vertices.values()):
if vertex in visited:
continue
subgraph = Subgraph()
subgraph.populate_from(vertex, visited)
subgraphs.append(subgraph)
return subgraphs
def subgraphs_to_json(self, subgraphs, max_depth_filter=-2):
"""
Returns a JSON-friendly object representing the list of subgraphs.
@type subgraphs: list
@param subgraphs: the list of subgraph objects
@type max_depth_filter: integer
@param max_depth_filter: maximum depth value filter
@rtype: list of dict
@return: list of subgraphs that can be converted to JSON
"""
graphs = []
total = 0
queue = deque()
visited = set()
maxPossibleDepth = -1
depth = -1
numVisibleNodes = 0
visibleCount = 0
for subgraph in subgraphs:
# putting vertices with highest degrees first seems to create
# better looking graphs
sorted_edges = sorted(subgraph.edges,
key=lambda e: (e[1].degree, e[0].degree,
e[1].title, e[0].title),
reverse=True)
edges = [{
'source': e[1].id,
'target': e[0].id
} for e in sorted_edges]
vertices = [{
'id': v.id,
'title': v.title,
'nodeDepth': v.node_depth
} for v in sorted(subgraph.vertices, key=lambda x: x.title)]
filtered_vertices = []
if max_depth_filter != -2:
for v in vertices:
if v.get('nodeDepth') <= max_depth_filter:
filtered_vertices.append(v)
else:
filtered_vertices = vertices
cycle = subgraph.has_cycle()
graphs.append({
'id': subgraph.id,
'edges': edges,
'vertices': filtered_vertices,
'has_cycle': cycle
})
if cycle and numVisibleNodes < MAX_SERVICES_SUPPORTED_IN_TREE_VIEW:
numVisibleNodes += len(subgraph.vertices)
for vertex in subgraph.vertices:
if vertex.node_depth == 0 and vertex not in visited:
visited.add(vertex)
queue.append(vertex)
visibleCount += len(filtered_vertices)
total += len(vertices)
if numVisibleNodes >= MAX_SERVICES_SUPPORTED_IN_TREE_VIEW:
# can't show anything, even nodes in cycles exceed MAX_SERVICES_SUPPORTED_IN_TREE_VIEW
return {
'graphs': graphs,
'totalCount': total,
'visibleCount': visibleCount,
'depth': -2,
'maxPossibleDepth': -2
}
while queue:
numInCurrLevel = len(queue)
for i in range(numInCurrLevel):
vertex = queue.popleft()
if numVisibleNodes < MAX_SERVICES_SUPPORTED_IN_TREE_VIEW:
numVisibleNodes += 1
for child in vertex.parents:
if child not in visited:
visited.add(child)
queue.append(child)
if numVisibleNodes < MAX_SERVICES_SUPPORTED_IN_TREE_VIEW:
depth += 1
maxPossibleDepth += 1
return {
'graphs': graphs,
'totalCount': total,
'visibleCount': visibleCount,
'depth': depth,
'maxPossibleDepth': maxPossibleDepth
}
class Subgraph(object):
"""
A subgraph representing a connected graph of vertices in the service tree.
"""
def __init__(self):
self.id = str(uuid.uuid4())
self.vertices = set()
self.edges = set()
def populate_from(self, start_vertex, visited):
"""
Populates this subgraph's vertices and edges data by traversing from
the given start vertex.
Also calls self.__assign_node_depth().
NOTE: The visited set param is modified during this call.
@type start_vertex: ServiceVertex
@param start_vertex: the vertex to start traversing from
@type visited: set
@param visited: set containing the already visited vertices
"""
stack = [start_vertex]
while stack:
v = stack.pop()
for child in v.children:
if child in visited:
continue
self.edges.add((v, child))
stack.append(child)
for parent in v.parents:
if parent in visited:
continue
self.edges.add((parent, v))
stack.append(parent)
self.vertices.add(v)
visited.add(v)
self.__assign_node_depth()
def __assign_node_depth(self):
"""
Private method that assigns node depth to each vertex in the subgraph.
NOTE: Must only be called from within the populate_from() method.
If a cycle is detected, all vertices get node_depth = -1.
If the desired tree from this subgraph
is a directed graph with arrows flowing "up" from bottom to top
then "root" vertices would be at the top, and have no outgoing arrows (thus no children).
NOTE: This is the current expected behavior.
However, if the desired tree from this subgraph
is a directed graph with arrows flowing "down" from top to bottom
then "root" vertices would still be at the top, and have no incoming arrows (thus no parents).
"""
# for cycles, node_depth = -1 (which is also the default assignment of node_depth in the ServiceVertex
# constructor)
if self.has_cycle():
return
# find "root" nodes (should have no children) - NOTE: see method description
roots = set([v for v in self.vertices if len(v.children) == 0])
if len(roots) == 0:
print("Every vertex has a child, which means a cycle. self.has_cycle() should have detected this.")
return
# do multi-source BFS to assign node_depth to each vertex
visited = set()
q = deque()
depth = 0
# start with the roots
for root in roots:
visited.add(root)
q.append(root)
while q:
level_size = len(q)
for i in range(level_size):
curr = q.popleft()
curr.node_depth = depth
for v in curr.parents: # would be curr.children if going "down" - NOTE: see method description
if v not in visited:
visited.add(v)
q.append(v)
depth += 1
def has_cycle(self):
"""
Returns whether this subgraph contains a cycle.
Algorithm inspired by: https://www.geeksforgeeks.org/detect-cycle-direct-graph-using-colors/
@rtype: bool
@return: whether the subgraph contains a cycle
"""
# Initially put all vertices in an unprocessed state
unprocessed = self.vertices.copy()
# Keep track of vertices that haven't been fully processed yet
processing = set()
for vertex in self.vertices:
# Starting from an unprocessed vertex, look for back edges in order to detect cycles
if vertex in unprocessed and self._dfs_cycle(vertex, unprocessed, processing):
return True
return False
def _dfs_cycle(self, vertex, unprocessed, processing):
"""
Helper method that performs a DFS travel on a given vertex, detecting cycles.
@rtype: bool
@return: whether a cycle's been detected
"""
if vertex in unprocessed:
unprocessed.remove(vertex)
# Mark this vertex as being processed
processing.add(vertex)
for child in vertex.children:
if child in processing or (child in unprocessed and self._dfs_cycle(child, unprocessed, processing)):
# Found a back edge, cycle detected
return True
# This vertex is fully processed, so remove it from a processing state
processing.remove(vertex)
return False
class ServiceVertex(object):
"""
A vertex in the service tree, containing parent and children edge data.
"""
def __init__(self, id):
self.id = id
self.title = ''
self.children = set()
self.parents = set()
self.node_depth = -1
def _traverse(self, edge_attr, visited):
"""
Traverses the connected graph through the children of this vertex.
@type edge_attr: str
@param edge_attr: the edge attribute name to use for the traversal
@type visited: set
@param visited: set containing the already visited vertices
"""
stack = [self]
while stack:
v = stack.pop()
stack.extend([n for n in getattr(v, edge_attr)
if n not in visited])
visited.add(v)
def traverse_children(self, visited):
"""
Traverses the connected graph through the children of this vertex.
@type visited: set
@param visited: set containing the already visited vertices
"""
self._traverse('children', visited)
def traverse_parents(self, visited):
"""
Traverses the connected graph through the parents of this vertex.
@type visited: set
@param visited: set containing the already visited vertices
"""
self._traverse('parents', visited)
@property
def degree(self):
"""
Returns the degree of the vertex.
@rtype: int
@return: the number of edges connected to the vertex
"""
return len(self.children) + len(self.parents)
def __repr__(self):
return u'[ServiceVertex] %s' % self.id