mirror of
https://github.com/haidaraM/ansible-playbook-grapher
synced 2024-11-10 06:04:15 +00:00
fix: Only render role once for multiple playbooks and refactor
This commit is contained in:
parent
af67fd5f60
commit
4599b10f4f
5 changed files with 161 additions and 74 deletions
|
@ -23,18 +23,17 @@ from ansible.cli.arguments import option_helpers
|
|||
from ansible.errors import AnsibleOptionsError
|
||||
from ansible.release import __version__ as ansible_version
|
||||
from ansible.utils.display import Display, initialize_locale
|
||||
from graphviz import Digraph
|
||||
|
||||
from ansibleplaybookgrapher import __prog__, __version__
|
||||
from ansibleplaybookgrapher.graphbuilder import (
|
||||
GraphvizGraphBuilder,
|
||||
OPEN_PROTOCOL_HANDLERS,
|
||||
Grapher,
|
||||
)
|
||||
from ansibleplaybookgrapher.parser import PlaybookParser
|
||||
from ansibleplaybookgrapher.postprocessor import GraphVizPostProcessor
|
||||
|
||||
# The display is a singleton. This instruction will NOT return a new instance.
|
||||
# We explicitly set the verbosity after the init.
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
|
@ -58,37 +57,18 @@ class GrapherCLI(CLI, ABC):
|
|||
# Required to fix the warning "ansible.utils.display.initialize_locale has not been called..."
|
||||
initialize_locale()
|
||||
display.verbosity = self.options.verbosity
|
||||
playbook_nodes = []
|
||||
|
||||
digraph = Digraph(
|
||||
format="svg",
|
||||
graph_attr=GraphvizGraphBuilder.DEFAULT_GRAPH_ATTR,
|
||||
edge_attr=GraphvizGraphBuilder.DEFAULT_EDGE_ATTR,
|
||||
grapher = Grapher(self.options.playbook_filenames)
|
||||
grapher.parse(
|
||||
include_role_tasks=self.options.include_role_tasks,
|
||||
tags=self.options.tags,
|
||||
skip_tags=self.options.skip_tags,
|
||||
)
|
||||
digraph = grapher.graph(
|
||||
open_protocol_handler=self.options.open_protocol_handler,
|
||||
open_protocol_custom_formats=self.options.open_protocol_custom_formats,
|
||||
)
|
||||
|
||||
# parsing playbooks and build the graph
|
||||
for playbook_file in self.options.playbook_filenames:
|
||||
parser = PlaybookParser(
|
||||
tags=self.options.tags,
|
||||
skip_tags=self.options.skip_tags,
|
||||
playbook_filename=playbook_file,
|
||||
include_role_tasks=self.options.include_role_tasks,
|
||||
)
|
||||
|
||||
display.display(f"Parsing playbook {playbook_file}")
|
||||
|
||||
playbook_node = parser.parse()
|
||||
playbook_nodes.append(playbook_node)
|
||||
|
||||
GraphvizGraphBuilder(
|
||||
playbook_node,
|
||||
digraph=digraph,
|
||||
open_protocol_handler=self.options.open_protocol_handler,
|
||||
open_protocol_custom_formats=self.options.open_protocol_custom_formats,
|
||||
).build_graphviz_graph()
|
||||
|
||||
display.display("Rendering the graph...")
|
||||
|
||||
svg_path = digraph.render(
|
||||
cleanup=not self.options.save_dot_file,
|
||||
format="svg",
|
||||
|
@ -96,19 +76,18 @@ class GrapherCLI(CLI, ABC):
|
|||
view=self.options.view,
|
||||
)
|
||||
|
||||
post_processor = GraphVizPostProcessor(svg_path=svg_path)
|
||||
display.v("Post processing the SVG...")
|
||||
post_processor.post_process(grapher.playbook_nodes)
|
||||
post_processor.write()
|
||||
|
||||
display.display(f"The graph has been exported to {svg_path}", color="green")
|
||||
if self.options.save_dot_file:
|
||||
# add .dot extension. The render doesn't add an extension
|
||||
final_name = self.options.output_filename + ".dot"
|
||||
os.rename(self.options.output_filename, final_name)
|
||||
display.display(f"Graphviz dot file has been exported to {final_name}")
|
||||
|
||||
post_processor = GraphVizPostProcessor(svg_path=svg_path)
|
||||
display.v("Post processing the SVG...")
|
||||
post_processor.post_process(playbook_nodes)
|
||||
post_processor.write()
|
||||
|
||||
display.display(f"The graph has been exported to {svg_path}", color="green")
|
||||
|
||||
return svg_path
|
||||
|
||||
|
||||
|
|
|
@ -12,18 +12,19 @@
|
|||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import Dict, Optional, Tuple
|
||||
from typing import Dict, Optional, Tuple, List
|
||||
|
||||
from ansible.utils.display import Display
|
||||
from graphviz import Digraph
|
||||
|
||||
from ansibleplaybookgrapher import PlaybookParser
|
||||
from ansibleplaybookgrapher.graph import (
|
||||
PlaybookNode,
|
||||
RoleNode,
|
||||
BlockNode,
|
||||
Node,
|
||||
)
|
||||
from ansibleplaybookgrapher.utils import get_play_colors
|
||||
from ansibleplaybookgrapher.utils import get_play_colors, merge_dicts
|
||||
|
||||
display = Display()
|
||||
|
||||
|
@ -40,6 +41,92 @@ OPEN_PROTOCOL_HANDLERS = {
|
|||
}
|
||||
|
||||
|
||||
class Grapher:
|
||||
def __init__(self, playbook_filenames: List[str]):
|
||||
"""
|
||||
|
||||
:param playbook_filenames: List of playbooks to graph
|
||||
"""
|
||||
self.playbook_filenames = playbook_filenames
|
||||
# Colors assigned to plays
|
||||
|
||||
self.plays_color = {}
|
||||
# The usage of the roles in all playbooks
|
||||
self.roles_usage: Dict["RoleNode", List[str]] = {}
|
||||
|
||||
# The parsed playbooks
|
||||
self.playbook_nodes: List[PlaybookNode] = []
|
||||
|
||||
def parse(
|
||||
self,
|
||||
include_role_tasks: bool = False,
|
||||
tags: List[str] = None,
|
||||
skip_tags: List[str] = None,
|
||||
):
|
||||
"""
|
||||
Parses all the provided playbooks
|
||||
:param include_role_tasks: Should we include the role tasks
|
||||
:param tags: Only add plays and tasks tagged with these values
|
||||
:param skip_tags: Only add plays and tasks whose tags do not match these values
|
||||
:return:
|
||||
"""
|
||||
|
||||
for playbook_file in self.playbook_filenames:
|
||||
display.display(f"Parsing playbook {playbook_file}")
|
||||
parser = PlaybookParser(
|
||||
tags=tags,
|
||||
skip_tags=skip_tags,
|
||||
playbook_filename=playbook_file,
|
||||
include_role_tasks=include_role_tasks,
|
||||
)
|
||||
playbook_node = parser.parse()
|
||||
self.playbook_nodes.append(playbook_node)
|
||||
|
||||
# Setting colors for play
|
||||
for play in playbook_node.plays:
|
||||
# TODO: find a way to create visual distance between the generated colors
|
||||
# https://stackoverflow.com/questions/9018016/how-to-compare-two-colors-for-similarity-difference
|
||||
self.plays_color[play.id] = get_play_colors(play.id)
|
||||
|
||||
# Update the usage of the roles
|
||||
self.roles_usage = merge_dicts(
|
||||
self.roles_usage, playbook_node.roles_usage()
|
||||
)
|
||||
|
||||
def graph(
|
||||
self,
|
||||
open_protocol_handler: str,
|
||||
open_protocol_custom_formats: Dict[str, str] = None,
|
||||
) -> Digraph:
|
||||
"""
|
||||
Generate the digraph graph
|
||||
:param open_protocol_handler
|
||||
:param open_protocol_custom_formats
|
||||
:return:
|
||||
"""
|
||||
digraph = Digraph(
|
||||
format="svg",
|
||||
graph_attr=GraphvizGraphBuilder.DEFAULT_GRAPH_ATTR,
|
||||
edge_attr=GraphvizGraphBuilder.DEFAULT_EDGE_ATTR,
|
||||
)
|
||||
# Map of the rules that have been built so far for all playbooks
|
||||
roles_built = {}
|
||||
for p in self.playbook_nodes:
|
||||
builder = GraphvizGraphBuilder(
|
||||
p,
|
||||
digraph=digraph,
|
||||
roles_usage=self.roles_usage,
|
||||
roles_built=roles_built,
|
||||
play_colors=self.plays_color,
|
||||
open_protocol_handler=open_protocol_handler,
|
||||
open_protocol_custom_formats=open_protocol_custom_formats,
|
||||
)
|
||||
builder.build_graphviz_graph()
|
||||
roles_built.update(builder.roles_built)
|
||||
|
||||
return digraph
|
||||
|
||||
|
||||
class GraphvizGraphBuilder:
|
||||
"""
|
||||
Build the graphviz graph
|
||||
|
@ -58,6 +145,9 @@ class GraphvizGraphBuilder:
|
|||
playbook_node: "PlaybookNode",
|
||||
open_protocol_handler: str,
|
||||
digraph: Digraph,
|
||||
play_colors: Dict[str, Tuple[str, str]],
|
||||
roles_usage: Dict["RoleNode", List[str]] = None,
|
||||
roles_built: Dict = None,
|
||||
open_protocol_custom_formats: Dict[str, str] = None,
|
||||
):
|
||||
"""
|
||||
|
@ -68,11 +158,10 @@ class GraphvizGraphBuilder:
|
|||
:param open_protocol_custom_formats: The custom formats to use when the protocol handler is set to custom
|
||||
"""
|
||||
self.playbook_node = playbook_node
|
||||
self._play_colors = self._init_play_colors()
|
||||
# A map containing the roles that have been rendered so far
|
||||
self._rendered_roles = {}
|
||||
# FIXME: should be merged for all playbooks
|
||||
self.roles_usage = playbook_node.roles_usage()
|
||||
self.roles_usage = roles_usage or playbook_node.roles_usage()
|
||||
self.play_colors = play_colors
|
||||
# A map containing the roles that have been built so far
|
||||
self.roles_built = roles_built or {}
|
||||
|
||||
self.open_protocol_handler = open_protocol_handler
|
||||
# Merge the two dicts
|
||||
|
@ -81,19 +170,6 @@ class GraphvizGraphBuilder:
|
|||
|
||||
self.digraph = digraph
|
||||
|
||||
def _init_play_colors(self) -> Dict[str, Tuple[str, str]]:
|
||||
"""
|
||||
Get random colors for each play in the playbook: color and font color
|
||||
:return:
|
||||
"""
|
||||
colors = {}
|
||||
for p in self.playbook_node.plays:
|
||||
colors[p.id] = get_play_colors(p.id)
|
||||
|
||||
# TODO: find a way to create visual distance between the generated colors
|
||||
# https://stackoverflow.com/questions/9018016/how-to-compare-two-colors-for-similarity-difference
|
||||
return colors
|
||||
|
||||
def build_node(
|
||||
self,
|
||||
graph: Digraph,
|
||||
|
@ -256,16 +332,16 @@ class GraphvizGraphBuilder:
|
|||
labeltooltip=role_edge_label,
|
||||
)
|
||||
|
||||
# Merge the colors for each play where this role is used
|
||||
role_plays = self.roles_usage[destination]
|
||||
colors = list(map(self._play_colors.get, role_plays))
|
||||
# Graphviz support providing multiple colors separated by :
|
||||
role_color = ":".join([c[0] for c in colors])
|
||||
|
||||
# check if we already rendered this role
|
||||
role_to_render = self._rendered_roles.get(destination.name, None)
|
||||
# check if we already built this role
|
||||
role_to_render = self.roles_built.get(destination.name, None)
|
||||
if role_to_render is None:
|
||||
self._rendered_roles[destination.name] = destination
|
||||
# Merge the colors for each play where this role is used
|
||||
role_plays = self.roles_usage[destination]
|
||||
colors = list(map(self.play_colors.get, role_plays))
|
||||
# Graphviz support providing multiple colors separated by :
|
||||
role_color = ":".join([c[0] for c in colors])
|
||||
|
||||
self.roles_built[destination.name] = destination
|
||||
|
||||
with graph.subgraph(name=destination.name, node_attr={}) as role_subgraph:
|
||||
role_subgraph.node(
|
||||
|
@ -285,6 +361,8 @@ class GraphvizGraphBuilder:
|
|||
counter=role_task_counter,
|
||||
color=role_color,
|
||||
)
|
||||
else:
|
||||
print("here")
|
||||
|
||||
def build_graphviz_graph(self):
|
||||
"""
|
||||
|
@ -302,7 +380,7 @@ class GraphvizGraphBuilder:
|
|||
|
||||
for play_counter, play in enumerate(self.playbook_node.plays, 1):
|
||||
with self.digraph.subgraph(name=play.name) as play_subgraph:
|
||||
color, play_font_color = self._play_colors[play.id]
|
||||
color, play_font_color = self.play_colors[play.id]
|
||||
play_tooltip = (
|
||||
",".join(play.hosts) if len(play.hosts) > 0 else play.name
|
||||
)
|
||||
|
|
|
@ -15,7 +15,10 @@
|
|||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
from typing import Tuple, List
|
||||
from collections import defaultdict
|
||||
from itertools import chain
|
||||
from operator import methodcaller
|
||||
from typing import Tuple, List, Dict, Any
|
||||
|
||||
from ansible.errors import AnsibleError
|
||||
from ansible.module_utils._text import to_text
|
||||
|
@ -108,6 +111,22 @@ def has_role_parent(task_block: Task) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def merge_dicts(dict_1: Dict[Any, List], dict_2: Dict[Any, List]) -> Dict[Any, List]:
|
||||
"""
|
||||
Merge two dicts by grouping keys and appending values in list
|
||||
:param dict_1:
|
||||
:param dict_2:
|
||||
:return:
|
||||
"""
|
||||
final = defaultdict(list)
|
||||
# iterate dict items
|
||||
all_dict_items = map(methodcaller("items"), [dict_1, dict_2])
|
||||
for k, v in chain.from_iterable(all_dict_items):
|
||||
final[k].extend(v)
|
||||
|
||||
return final
|
||||
|
||||
|
||||
def handle_include_path(
|
||||
original_task: TaskInclude, loader: DataLoader, templar: Templar
|
||||
) -> str:
|
||||
|
|
|
@ -433,17 +433,17 @@ def test_multiple_playbooks(request):
|
|||
svg_path, playbook_paths = run_grapher(
|
||||
["multi-plays.yml", "relative_var_files.yml", "with_roles.yml"],
|
||||
output_filename=request.node.name,
|
||||
additional_args=["--include-role-tasks"],
|
||||
additional_args=["--include-role-tasks", "--save-dot-file"],
|
||||
)
|
||||
_common_tests(
|
||||
svg_path=svg_path,
|
||||
playbook_paths=playbook_paths,
|
||||
playbooks_number=3,
|
||||
plays_number=3 + 1 + 1,
|
||||
pre_tasks_number=2 + 0 + 2,
|
||||
plays_number=5,
|
||||
pre_tasks_number=4,
|
||||
roles_number=3,
|
||||
tasks_number=10 + 2 + 8,
|
||||
post_tasks_number=2 + 0 + 2,
|
||||
tasks_number=14,
|
||||
post_tasks_number=4,
|
||||
)
|
||||
|
||||
|
||||
|
|
11
tests/test_utils.py
Normal file
11
tests/test_utils.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from ansibleplaybookgrapher.utils import merge_dicts
|
||||
|
||||
|
||||
def test_merge_dicts():
|
||||
"""
|
||||
Test dicts grouping
|
||||
:return:
|
||||
"""
|
||||
|
||||
res = merge_dicts({"1": [2, 3], "4": [5], "9": [11]}, {"4": [7], "9": []})
|
||||
assert res == {"1": [2, 3], "4": [5, 7], "9": [11]}
|
Loading…
Reference in a new issue