fix: Only render role once for multiple playbooks and refactor

This commit is contained in:
Mohamed El Mouctar HAIDARA 2022-08-15 02:37:03 +02:00
parent af67fd5f60
commit 4599b10f4f
5 changed files with 161 additions and 74 deletions

View file

@ -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

View file

@ -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
)

View file

@ -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:

View file

@ -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
View 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]}