feat: Add support for hiding empty plays and plays without roles (#177)

This commit is contained in:
Mohamed El Mouctar Haidara 2024-03-25 19:08:11 +01:00 committed by GitHub
parent 9155fa8065
commit 21d54107ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 345 additions and 54 deletions

View file

@ -26,7 +26,7 @@ JavaScript:
the files for the others nodes. The cursor will be at the task exact position in the file.
Lastly, you can provide your own protocol formats
with `--open-protocol-handler custom --open-protocol-custom-formats '{}'`. See the help
and [an example.](https://github.com/haidaraM/ansible-playbook-grapher/blob/12cee0fbd59ffbb706731460e301f0b886515357/ansibleplaybookgrapher/graphbuilder.py#L33-L42).
and [an example.](https://github.com/haidaraM/ansible-playbook-grapher/blob/12cee0fbd59ffbb706731460e301f0b886515357/ansibleplaybookgrapher/graphbuilder.py#L33-L42)
- Filer tasks based on tags
- Export the dot file used to generate the graph with Graphviz.

View file

@ -76,6 +76,8 @@ class PlaybookGrapherCLI(CLI):
output_filename=self.options.output_filename,
view=self.options.view,
save_dot_file=self.options.save_dot_file,
hide_empty_plays=self.options.hide_empty_plays,
hide_plays_without_roles=self.options.hide_plays_without_roles,
)
return output_path
@ -91,6 +93,8 @@ class PlaybookGrapherCLI(CLI):
view=self.options.view,
directive=self.options.renderer_mermaid_directive,
orientation=self.options.renderer_mermaid_orientation,
hide_empty_plays=self.options.hide_empty_plays,
hide_plays_without_roles=self.options.hide_plays_without_roles,
)
return output_path
@ -114,7 +118,7 @@ class PlaybookGrapherCLI(CLI):
dest="include_role_tasks",
action="store_true",
default=False,
help="Include the tasks of the role in the graph.",
help="Include the tasks of the roles in the graph. Applied when parsing the playbooks.",
)
self.parser.add_argument(
@ -205,6 +209,21 @@ class PlaybookGrapherCLI(CLI):
version=f"{__prog__} {__version__} (with ansible {ansible_version})",
)
self.parser.add_argument(
"--hide-plays-without-roles",
action="store_true",
default=False,
help="Hide the plays that end up with no roles in the graph (after applying the tags filter). "
"Only roles at the play level and include_role as tasks are considered (no import_role).",
)
self.parser.add_argument(
"--hide-empty-plays",
action="store_true",
default=False,
help="Hide the plays that end up with no tasks in the graph (after applying the tags filter).",
)
self.parser.add_argument(
"playbook_filenames",
help="Playbook(s) to graph",

View file

@ -14,7 +14,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import os
from collections import defaultdict
from typing import Dict, List, Set, Type, Tuple, Optional
from typing import Dict, List, Set, Tuple, Optional
from ansibleplaybookgrapher.utils import generate_id, get_play_colors
@ -79,7 +79,7 @@ class Node:
if self.raw_object and self.raw_object.get_ds():
self.path, self.line, self.column = self.raw_object.get_ds().ansible_pos
def get_first_parent_matching_type(self, node_type: Type) -> Type:
def get_first_parent_matching_type(self, node_type: type) -> type:
"""
Get the first parent of this node matching the given type
:param node_type: The type of the parent to get
@ -164,7 +164,7 @@ class CompositeNode(Node):
node.index = self._node_counter + 1
self._node_counter += 1
def get_node(self, target_composition: str) -> List:
def get_nodes(self, target_composition: str) -> List:
"""
Get a node from the compositions
:param target_composition:
@ -221,6 +221,33 @@ class CompositeNode(Node):
node._get_all_links(links)
links[self].append(node)
def is_empty(self) -> bool:
"""
Returns true if the composite node is empty, false otherwise
:return:
"""
for _, nodes in self._compositions.items():
if len(nodes) > 0:
return False
return True
def has_node_type(self, node_type: type) -> bool:
"""
Returns true if the composite node has at least one node of the given type, false otherwise
:param node_type: The type of the node
:return:
"""
for _, nodes in self._compositions.items():
for node in nodes:
if isinstance(node, node_type):
return True
if isinstance(node, CompositeNode):
return node.has_node_type(node_type)
return False
class CompositeTasksNode(CompositeNode):
"""
@ -261,7 +288,7 @@ class CompositeTasksNode(CompositeNode):
The tasks attached to this block
:return:
"""
return self.get_node("tasks")
return self.get_nodes("tasks")
class PlaybookNode(CompositeNode):
@ -296,13 +323,24 @@ class PlaybookNode(CompositeNode):
self.line = 1
self.column = 1
@property
def plays(self) -> List["PlayNode"]:
def plays(
self, exclude_empty: bool = False, exclude_without_roles: bool = False
) -> List["PlayNode"]:
"""
Return the list of plays
:param exclude_empty: Whether to exclude the empty plays from the result or not
:param exclude_without_roles: Whether to exclude the plays that do not have roles
:return:
"""
return self.get_node("plays")
plays = self.get_nodes("plays")
if exclude_empty:
plays = [play for play in plays if not play.is_empty()]
if exclude_without_roles:
plays = [play for play in plays if play.has_node_type(RoleNode)]
return plays
def roles_usage(self) -> Dict["RoleNode", Set["PlayNode"]]:
"""
@ -364,19 +402,23 @@ class PlayNode(CompositeNode):
@property
def roles(self) -> List["RoleNode"]:
return self.get_node("roles")
"""
Return the roles of the plays. Tasks using "include_role" are NOT returned.
:return:
"""
return self.get_nodes("roles")
@property
def pre_tasks(self) -> List["Node"]:
return self.get_node("pre_tasks")
return self.get_nodes("pre_tasks")
@property
def post_tasks(self) -> List["Node"]:
return self.get_node("post_tasks")
return self.get_nodes("post_tasks")
@property
def tasks(self) -> List["Node"]:
return self.get_node("tasks")
return self.get_nodes("tasks")
class BlockNode(CompositeTasksNode):

View file

@ -353,6 +353,16 @@ class PlaybookParser(BaseParser):
# See :func:`~ansible.playbook.included_file.IncludedFile.process_include_results` from line 155
display.v(f"An 'include_role' found: '{task_or_block.get_name()}'")
if not task_or_block.evaluate_tags(
only_tags=self.tags,
skip_tags=self.skip_tags,
all_vars=task_vars,
):
display.vv(
f"The include_role '{task_or_block.get_name()}' is skipped due to the tags."
)
continue # Go to the next task
# Here we are using the role name instead of the task name to keep the same behavior as a
# traditional role
if self.group_roles_by_name:

View file

@ -57,16 +57,20 @@ class Renderer(ABC):
open_protocol_custom_formats: Dict[str, str],
output_filename: str,
view: bool,
hide_empty_plays: bool = False,
hide_plays_without_roles: bool = False,
**kwargs,
) -> str:
"""
Render the playbooks to a file.
:param open_protocol_handler: The protocol handler name to use
:param open_protocol_custom_formats: The custom formats to use when the protocol handler is set to custom
:param output_filename: without any extension
:param output_filename: The output filename without any extension
:param view: Whether to open the rendered file in the default viewer
:param hide_empty_plays: Whether to hide empty plays or not when rendering the graph
:param hide_plays_without_roles: Whether to hide plays without any roles or not
:param kwargs:
:return: The filename of the rendered file
:return: The path of the rendered file
"""
pass
@ -128,9 +132,16 @@ class PlaybookBuilder(ABC):
)
@abstractmethod
def build_playbook(self, **kwargs) -> str:
def build_playbook(
self,
hide_empty_plays: bool = False,
hide_plays_without_roles: bool = False,
**kwargs,
) -> str:
"""
Build the whole playbook
:param hide_empty_plays: Whether to hide empty plays or not
:param hide_plays_without_roles:
:param kwargs:
:return: The rendered playbook as a string
"""

View file

@ -53,10 +53,18 @@ class GraphvizRenderer(Renderer):
open_protocol_custom_formats: Dict[str, str],
output_filename: str,
view: bool,
hide_empty_plays: bool = False,
hide_plays_without_roles=False,
**kwargs,
) -> str:
"""
:return: The filename where the playbooks where rendered
:param open_protocol_handler: The protocol handler name to use
:param open_protocol_custom_formats: The custom formats to use when the protocol handler is set to custom
:param output_filename: The output filename without any extension
:param view: Whether to open the rendered file in the default viewer
:param hide_empty_plays: Whether to hide empty plays or not when rendering the graph
:param hide_plays_without_roles: Whether to hide plays without any roles or not
:return: The path of the rendered file
"""
save_dot_file = kwargs.get("save_dot_file", False)
@ -76,7 +84,10 @@ class GraphvizRenderer(Renderer):
roles_built=roles_built,
digraph=digraph,
)
builder.build_playbook()
builder.build_playbook(
hide_empty_plays=hide_empty_plays,
hide_plays_without_roles=hide_plays_without_roles,
)
roles_built.update(builder.roles_built)
display.display("Rendering the graph...")
@ -274,9 +285,13 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
digraph=role_subgraph,
)
def build_playbook(self, **kwargs) -> str:
def build_playbook(
self, hide_empty_plays: bool = False, hide_plays_without_roles=False, **kwargs
) -> str:
"""
Convert the PlaybookNode to the graphviz dot format
:param hide_empty_plays: Whether to hide empty plays or not when rendering the graph
:param hide_plays_without_roles: Whether to hide plays without any roles or not
:return: The text representation of the graphviz dot format for the playbook
"""
display.vvv(f"Converting the graph to the dot format for graphviz")
@ -289,7 +304,10 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
URL=self.get_node_url(self.playbook_node, "file"),
)
for play in self.playbook_node.plays:
for play in self.playbook_node.plays(
exclude_empty=hide_empty_plays,
exclude_without_roles=hide_plays_without_roles,
):
with self.digraph.subgraph(name=play.name) as play_subgraph:
self.build_play(play, digraph=play_subgraph, **kwargs)

View file

@ -49,26 +49,32 @@ class MermaidFlowChartRenderer(Renderer):
open_protocol_custom_formats: Dict[str, str],
output_filename: str,
view: bool,
hide_empty_plays: bool = False,
hide_plays_without_roles: bool = False,
directive: str = DEFAULT_DIRECTIVE,
orientation: str = DEFAULT_ORIENTATION,
**kwargs,
) -> str:
"""
:param open_protocol_handler:
:param open_protocol_custom_formats:
:param output_filename: without any extension
:param view:
:param open_protocol_handler: Not supported for the moment
:param open_protocol_custom_formats: Not supported for the moment
:param output_filename: The output filename without any extension
:param view: Not supported for the moment
:param hide_empty_plays: Whether to hide empty plays or not when rendering the graph
:param hide_plays_without_roles: Whether to hide plays without any roles or not
:param directive: Mermaid directive
:param orientation: Mermaid graph orientation
:param kwargs:
:return:
"""
# TODO: Add support for protocol handler
# TODO: Add support for hover
mermaid_code = "---\n"
mermaid_code += "title: Ansible Playbook Grapher\n"
mermaid_code += "---\n"
directive = kwargs.get("directive", DEFAULT_DIRECTIVE)
orientation = kwargs.get("orientation", DEFAULT_ORIENTATION)
display.vvv(f"Using '{directive}' as directive for the mermaid chart")
mermaid_code += f"{directive}\n"
@ -90,7 +96,10 @@ class MermaidFlowChartRenderer(Renderer):
link_order=link_order,
)
mermaid_code += playbook_builder.build_playbook()
mermaid_code += playbook_builder.build_playbook(
hide_empty_plays=hide_empty_plays,
hide_plays_without_roles=hide_plays_without_roles,
)
link_order = playbook_builder.link_order
roles_built.update(playbook_builder.roles_built)
@ -130,15 +139,24 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
roles_usage,
roles_built,
)
self.mermaid_code = ""
# Used as an identifier for the links
self.link_order = link_order
# The current depth level of the nodes. Used for indentation
self._identation_level = 1
self._indentation_level = 1
def build_playbook(self, **kwargs) -> str:
def build_playbook(
self,
hide_empty_plays: bool = False,
hide_plays_without_roles=False,
**kwargs: bool,
) -> str:
"""
Build the playbook
:param hide_plays_without_roles: Whether to hide plays without any roles or not
:param hide_empty_plays: Whether to hide empty plays or not
:param hide_plays_without_roles: Whether to hide plays without any roles or not
:param kwargs:
:return:
"""
@ -150,10 +168,13 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
self.add_comment(f"Start of the playbook '{self.playbook_node.name}'")
self.add_text(f'{self.playbook_node.id}("{self.playbook_node.name}")')
self._identation_level += 1
for play_node in self.playbook_node.plays:
self._indentation_level += 1
for play_node in self.playbook_node.plays(
exclude_empty=hide_empty_plays,
exclude_without_roles=hide_plays_without_roles,
):
self.build_play(play_node)
self._identation_level -= 1
self._indentation_level -= 1
self.add_comment(f"End of the playbook '{self.playbook_node.name}'\n")
@ -182,9 +203,9 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
)
# traverse the play
self._identation_level += 1
self._indentation_level += 1
self.traverse_play(play_node)
self._identation_level -= 1
self._indentation_level -= 1
self.add_comment(f"End of the play '{play_node.name}'")
@ -249,14 +270,14 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
)
# Role tasks
self._identation_level += 1
self._indentation_level += 1
for role_task in role_node.tasks:
self.build_node(
node=role_task,
color=node_color,
fontcolor=fontcolor,
)
self._identation_level -= 1
self._indentation_level -= 1
self.add_comment(f"End of the role '{role_node.name}'")
@ -287,14 +308,14 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
self.add_text(f'subgraph subgraph_{block_node.id}["{block_node.name} "]')
self._identation_level += 1
self._indentation_level += 1
for task in block_node.tasks:
self.build_node(
node=task,
color=color,
fontcolor=fontcolor,
)
self._identation_level -= 1
self._indentation_level -= 1
self.add_text("end") # End of the subgraph
self.add_comment(f"End of the block '{block_node.name}'")
@ -347,4 +368,4 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
Return the current indentation level as tabulations
:return:
"""
return "\t" * self._identation_level
return "\t" * self._indentation_level

View file

@ -21,7 +21,7 @@ from operator import methodcaller
from typing import Tuple, List, Dict, Any, Set
from ansible.errors import AnsibleError
from ansible.module_utils._text import to_text
from ansible.module_utils.common.text.converters import to_text
from ansible.parsing.dataloader import DataLoader
from ansible.playbook.role_include import IncludeRole
from ansible.playbook.task import Task

View file

@ -1,7 +1,7 @@
---
- hosts: all
tags:
- play2
- play1
roles:
- role: fake_role
tags:

View file

@ -1,7 +1,7 @@
---
- hosts: all
tags:
- play2
- play1
roles:
- fake_role
- display_some_facts

22
tests/fixtures/play-hiding.yml vendored Normal file
View file

@ -0,0 +1,22 @@
---
- hosts: host1
tags:
- play1
roles:
- role: with-dependencies
- hosts: host2
tags:
- play2
tasks:
- name: debug
debug: msg="Post task 1"
- name: Include role
include_role:
name: fake_role
- hosts: host3 # This should not be displayed when --hide-empty-plays is set
tags:
- play3

View file

@ -1,7 +1,7 @@
---
- hosts: all
tags:
- play2
- play1
roles:
- role: with-dependencies

View file

@ -66,3 +66,36 @@ def test_get_all_tasks_nodes():
all_tasks = play.get_all_tasks()
assert len(all_tasks) == 4, "There should be 4 tasks in all"
assert [task_1, task_2, task_3, task_4] == all_tasks
def test_empty_play():
"""
Testing the emptiness of a play
:return:
"""
play = PlayNode("play")
assert play.is_empty(), "The play should empty"
play.add_node("roles", RoleNode("my_role_1"))
assert not play.is_empty(), "The play should not be empty"
def test_has_node_type():
"""
Testing the method has_node_type
:return:
"""
play = PlayNode("play")
block = BlockNode("block 1")
role = RoleNode("my_role")
role.add_node("tasks", TaskNode("task 1"))
block.add_node("tasks", role)
play.add_node("tasks", block)
assert play.has_node_type(BlockNode), "The play should have BlockNode"
assert play.has_node_type(RoleNode), "The play should have a RoleNode"
assert play.has_node_type(TaskNode), "The play should have a TaskNode"
assert not role.has_node_type(BlockNode), "The role doesn't have a BlockNode"

View file

@ -541,3 +541,118 @@ def test_group_roles_by_name(
post_tasks_number=post_tasks_number,
blocks_number=1,
)
def test_hiding_plays(request):
"""
Test hiding_plays with the flag --hide-empty-plays.
This case is about hiding plays with 0 zero task (no filtering)
:param request:
:return:
"""
svg_path, playbook_paths = run_grapher(
["play-hiding.yml"],
output_filename=request.node.name,
additional_args=["--hide-empty-plays"],
)
_common_tests(
svg_path=svg_path,
playbook_paths=playbook_paths,
plays_number=2,
roles_number=2,
tasks_number=1,
)
def test_hiding_empty_plays_with_tags_filter(request):
"""
Test hiding plays with the flag --hide-empty-plays.
This case is about hiding plays when filtering with tags
:param request:
:return:
"""
svg_path, playbook_paths = run_grapher(
["play-hiding.yml"],
output_filename=request.node.name,
additional_args=["--hide-empty-plays", "--tags", "play1"],
)
_common_tests(
svg_path=svg_path, playbook_paths=playbook_paths, plays_number=1, roles_number=1
)
def test_hiding_empty_plays_with_tags_filter_all(request):
"""
Test hiding plays with the flag --hide-empty-plays.
This case is about hiding ALL the plays when filtering with tags
:param request:
:return:
"""
svg_path, playbook_paths = run_grapher(
["play-hiding.yml"],
output_filename=request.node.name,
additional_args=[
"--hide-empty-plays",
"--tags",
"fake-tag-that-does-not-exist",
],
)
_common_tests(svg_path=svg_path, playbook_paths=playbook_paths)
def test_hiding_plays_without_roles(request):
"""
Test hiding plays with the flag --hide-plays-without-roles
:param request:
:return:
"""
svg_path, playbook_paths = run_grapher(
["play-hiding.yml"],
output_filename=request.node.name,
additional_args=[
"--hide-plays-without-roles",
],
)
_common_tests(
svg_path=svg_path,
playbook_paths=playbook_paths,
plays_number=2,
roles_number=2,
tasks_number=1,
)
def test_hiding_plays_without_roles_with_tags_filtering(request):
"""
Test hiding plays with the flag --hide-plays-without-roles
Also apply some tags filter
:param request:
:return:
"""
svg_path, playbook_paths = run_grapher(
["play-hiding.yml"],
output_filename=request.node.name,
additional_args=[
"--hide-plays-without-roles",
"--tags",
"play1",
"--include-role-tasks",
],
)
_common_tests(
svg_path=svg_path,
playbook_paths=playbook_paths,
plays_number=1,
roles_number=1,
tasks_number=5,
)

View file

@ -50,7 +50,7 @@ def run_grapher(
def _common_tests(mermaid_path: str, playbook_paths: List[str], **kwargs):
"""
Some common tests for mermaid renderer
:param mermaid_path:
:param playbook_paths:
:param kwargs:

View file

@ -48,7 +48,7 @@ def test_example_parsing(grapher_cli: PlaybookGrapherCLI, display: Display):
"""
parser = PlaybookParser(grapher_cli.options.playbook_filenames[0])
playbook_node = parser.parse()
assert len(playbook_node.plays) == 1
assert len(playbook_node.plays()) == 1
assert playbook_node.path == os.path.join(FIXTURES_PATH, "example.yml")
assert playbook_node.line == 1
assert playbook_node.column == 1
@ -56,7 +56,7 @@ def test_example_parsing(grapher_cli: PlaybookGrapherCLI, display: Display):
playbook_node.index is None
), "The index of the playbook should be None (it has no parent)"
play_node = playbook_node.plays[0]
play_node = playbook_node.plays()[0]
assert play_node.path == os.path.join(FIXTURES_PATH, "example.yml")
assert play_node.line == 2
assert play_node.index == 1
@ -90,8 +90,8 @@ def test_with_roles_parsing(grapher_cli: PlaybookGrapherCLI):
"""
parser = PlaybookParser(grapher_cli.options.playbook_filenames[0])
playbook_node = parser.parse()
assert len(playbook_node.plays) == 1
play_node = playbook_node.plays[0]
assert len(playbook_node.plays()) == 1
play_node = playbook_node.plays()[0]
assert play_node.index == 1
assert len(play_node.roles) == 2
@ -127,8 +127,8 @@ def test_include_role_parsing(grapher_cli: PlaybookGrapherCLI, capsys):
grapher_cli.options.playbook_filenames[0], include_role_tasks=True
)
playbook_node = parser.parse()
assert len(playbook_node.plays) == 1
play_node = playbook_node.plays[0]
assert len(playbook_node.plays()) == 1
play_node = playbook_node.plays()[0]
tasks = play_node.tasks
assert len(tasks) == 6
@ -194,9 +194,9 @@ def test_block_parsing(grapher_cli: PlaybookGrapherCLI):
grapher_cli.options.playbook_filenames[0], include_role_tasks=True
)
playbook_node = parser.parse()
assert len(playbook_node.plays) == 1
assert len(playbook_node.plays()) == 1
play_node = playbook_node.plays[0]
play_node = playbook_node.plays()[0]
pre_tasks = play_node.pre_tasks
tasks = play_node.tasks
post_tasks = play_node.post_tasks
@ -360,7 +360,7 @@ def test_roles_dependencies(grapher_cli: PlaybookGrapherCLI):
grapher_cli.options.playbook_filenames[0], include_role_tasks=True
)
playbook_node = parser.parse()
roles = playbook_node.plays[0].roles
roles = playbook_node.plays()[0].roles
assert len(roles) == 1, "Only one explicit role is called inside the playbook"
role_with_dependencies = roles[0]
tasks = role_with_dependencies.tasks