feat: Add initial support for handlers ()

Related  


- Add the handlers to the graph with `--show-handlers`. **This is the
initial support for handlers.** They are by default added at the end of
the play and roles only. This doesn't reflect Ansible behavior.
- Changes the shape of the graphviz node to make it consistent with
Mermaid. The tasks will be rectangle instead of `octagon`:
https://graphviz.org/doc/info/shapes.html
- Refactor how the node/task index are computed given we can now add
handlers after all the tasks are parsed.
- Add a new `display_name()` method to the node for a friendly name for
the graph. This removes the of passing the `node_label_prefix` in
multiple places.
- Remove the class **CompositeTasksNode** as it is no longer needed
anymore.
- Remove the play name from the edge going from playbook to the plays.
This was not consistent with the other edges.
This commit is contained in:
Mohamed El Mouctar Haidara 2024-12-22 23:13:38 +01:00 committed by GitHub
parent 5ccfed2ea3
commit df7be04e6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 806 additions and 160 deletions

View file

@ -35,6 +35,7 @@ JavaScript:
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/34e0aef74b82808dceb6ccfbeb333c0b531eac12/ansibleplaybookgrapher/renderer/__init__.py#L32-L41)
To not confuse with [Ansible Handlers.](https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_handlers.html)
- Export the dot file used to generate the graph with Graphviz.
## Prerequisites
@ -79,8 +80,9 @@ At the time of writing, two renderers are supported:
2. `mermaid-flowchart`: Generate the graph in [Mermaid](https://mermaid.js.org/syntax/flowchart.html) format. You can
directly embed the graph in your Markdown and GitHub (
and [other integrations](https://mermaid.js.org/ecosystem/integrations.html)) will render it.
3. `json`: Generate a JSON representation of the graph to be used by other tools. The corresponding JSON schema
is [here.](https://github.com/haidaraM/ansible-playbook-grapher/tree/main/tests/fixtures/json-schemas)
3. `json`: Generate a JSON representation of the graph. The corresponding JSON schema
is [here.](https://github.com/haidaraM/ansible-playbook-grapher/tree/main/tests/fixtures/json-schemas) The JSON
output will give you more flexibility to create your own renderer.
If you are interested to support more renderers, feel free to create an issue or raise a PR based on the existing
renderers.
@ -396,11 +398,14 @@ regarding the blocks.
The available options:
```
usage: ansible-playbook-grapher [-h] [-v] [-i INVENTORY] [--include-role-tasks] [-s] [--view] [-o OUTPUT_FILENAME]
[--open-protocol-handler {default,vscode,custom}] [--open-protocol-custom-formats OPEN_PROTOCOL_CUSTOM_FORMATS]
[--group-roles-by-name] [--renderer {graphviz,mermaid-flowchart,json}] [--renderer-mermaid-directive RENDERER_MERMAID_DIRECTIVE]
[--renderer-mermaid-orientation {TD,RL,BT,RL,LR}] [--version] [--hide-plays-without-roles] [--hide-empty-plays] [-t TAGS]
[--skip-tags SKIP_TAGS] [--vault-id VAULT_IDS] [-J | --vault-password-file VAULT_PASSWORD_FILES] [-e EXTRA_VARS]
usage: ansible-playbook-grapher [-h] [-v] [--exclude-roles EXCLUDE_ROLES] [--only-roles] [-i INVENTORY] [--include-role-tasks] [-s]
[--view] [-o OUTPUT_FILENAME] [--open-protocol-handler {default,vscode,custom}]
[--open-protocol-custom-formats OPEN_PROTOCOL_CUSTOM_FORMATS] [--group-roles-by-name]
[--renderer {graphviz,mermaid-flowchart,json}]
[--renderer-mermaid-directive RENDERER_MERMAID_DIRECTIVE]
[--renderer-mermaid-orientation {TD,RL,BT,RL,LR}] [--version] [--hide-plays-without-roles]
[--hide-empty-plays] [--show-handlers] [-t TAGS] [--skip-tags SKIP_TAGS] [--vault-id VAULT_IDS] [-J |
--vault-password-file VAULT_PASSWORD_FILES] [-e EXTRA_VARS]
playbooks [playbooks ...]
Make graphs from your Ansible Playbooks.
@ -418,7 +423,7 @@ options:
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).
--include-role-tasks Include the tasks of the roles in the graph. Applied when parsing the playbooks.
--only-roles Ignore all tasks when rendering graphs.
--only-roles Only display the roles in the graph (ignoring the tasks)
--open-protocol-custom-formats OPEN_PROTOCOL_CUSTOM_FORMATS
The custom formats to use as URLs for the nodes in the graph. Required if --open-protocol-handler is set to custom. You should provide a
JSON formatted string like: {"file": "", "folder": ""}. Example: If you want to open folders (roles) inside the browser and files
@ -437,12 +442,13 @@ options:
https://mermaid.js.org/config/directives.html. Default: '%%{ init: { "flowchart": { "curve": "bumpX" } } }%%'
--renderer-mermaid-orientation {TD,RL,BT,RL,LR}
The orientation of the flow chart. Default: 'LR'
--show-handlers Show the handlers in the graph. See the limitations in the project README on GitHub.
--skip-tags SKIP_TAGS
only run plays and tasks whose tags do not match these values. This argument may be specified multiple times.
--vault-id VAULT_IDS the vault identity to use. This argument may be specified multiple times.
--vault-password-file VAULT_PASSWORD_FILES, --vault-pass-file VAULT_PASSWORD_FILES
vault password file
--version
--version show program's version number and exit
--view Automatically open the resulting SVG file with your system's default viewer application for the file type
-J, --ask-vault-password, --ask-vault-pass
ask for vault password
@ -480,6 +486,9 @@ More information [here](https://docs.ansible.com/ansible/latest/reference_append
Always check the edge label to know the task order.
- The label of the edges may overlap with each other. They are positioned so that they are as close as possible to
the target nodes. If the same role is used in multiple plays or playbooks, the labels can overlap.
- **Ansible Handlers**: The handlers are partially supported for the moment. Their position in the graph doesn't entirely
reflect their real order of execution in the playbook. They are displayed at the end of the play and roles, but they
might be executed before that.
## Contribution

View file

@ -97,6 +97,7 @@ class PlaybookGrapherCLI(CLI):
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,
show_handlers=self.options.show_handlers,
)
case "mermaid-flowchart":
@ -113,6 +114,7 @@ class PlaybookGrapherCLI(CLI):
orientation=self.options.renderer_mermaid_orientation,
hide_empty_plays=self.options.hide_empty_plays,
hide_plays_without_roles=self.options.hide_plays_without_roles,
show_handlers=self.options.show_handlers,
)
case "json":
@ -124,6 +126,7 @@ class PlaybookGrapherCLI(CLI):
view=self.options.view,
hide_empty_plays=self.options.hide_empty_plays,
hide_plays_without_roles=self.options.hide_plays_without_roles,
show_handlers=self.options.show_handlers,
)
case _:
@ -303,6 +306,14 @@ class PlaybookGrapherCLI(CLI):
help="Hide the plays that end up with no tasks in the graph (after applying the tags filter).",
)
self.parser.add_argument(
"--show-handlers",
dest="show_handlers",
action="store_true",
default=False,
help="Show the handlers in the graph. See the limitations in the project README on GitHub.",
)
self.parser.add_argument(
"playbooks",
help="Playbook(s) to graph. You can specify multiple playbooks, separated by spaces and reference playbooks in collections.",
@ -357,7 +368,7 @@ class PlaybookGrapherCLI(CLI):
if self.options.open_protocol_handler == "custom":
self.validate_open_protocol_custom_formats()
# create list of roles to exclude
# create the list of roles to exclude
if options.exclude_roles:
exclude_roles = set()
for arg in options.exclude_roles:
@ -376,6 +387,13 @@ class PlaybookGrapherCLI(CLI):
options.exclude_roles = sorted(exclude_roles)
if self.options.show_handlers:
display.warning(
"The handlers are partially supported for the moment. Their position in the graph doesn't entirely reflect "
"their real order of execution in the playbook. They are displayed at the end of the play and roles, "
"but they might be executed before that."
)
return options
def validate_open_protocol_custom_formats(self) -> None:

View file

@ -16,6 +16,8 @@ from collections import defaultdict
from dataclasses import asdict, dataclass
from typing import Any, Type, TypeVar
from ansible.playbook.handler import Handler
from ansibleplaybookgrapher.utils import generate_id, get_play_colors
@ -61,7 +63,6 @@ class Node:
when: str = "",
raw_object: Any = None,
parent: "Node" = None,
index: int | None = None,
) -> None:
""":param node_name: The name of the node
:param node_id: An identifier for this node
@ -80,7 +81,29 @@ class Node:
self.set_location()
# The index of this node in the parent node if it has one (starting from 1)
self.index: int | None = index
self.index: int | None = None
def display_name(self) -> str:
"""Return the display name of the node.
It's composed of the ID prefix between brackets and the name of the node.
Examples:
- [playbook] My playbook
- [play] My play
- [pre_task] My pre task
- [role] My role
- [task] My task
- [block] My block
- [post_task] My post task
:return:
"""
try:
split = self.id.split("_")
id_prefix = "_".join(split[:-1])
return f"[{id_prefix}] {self.name}"
except IndexError:
return self.name
def set_location(self) -> None:
"""Set the location of this node based on the raw object. Not all objects have path.
@ -99,13 +122,13 @@ class Node:
column=column,
)
else:
# Here we likely have a task a validate argument spec task inserted by Ansible
# Here we likely have a task with a validate argument spec task inserted by Ansible
pass
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
:param node_type: The type of the parent node to get.
:return:
"""
current_parent = self.parent
@ -171,7 +194,6 @@ class CompositeNode(Node):
when: str = "",
raw_object: Any = None,
parent: "Node" = None,
index: int | None = None,
supported_compositions: list[str] | None = None,
) -> None:
"""Init a composite node.
@ -188,13 +210,10 @@ class CompositeNode(Node):
when=when,
raw_object=raw_object,
parent=parent,
index=index,
)
self._supported_compositions = supported_compositions or []
# The dict will contain the different types of composition: plays, tasks, roles...
self._compositions = defaultdict(list) # type: Dict[str, List]
# Used to count the number of nodes in this composite node
self._node_counter = 0
self._compositions = defaultdict(list) # type: dict[str, list]
def add_node(self, target_composition: str, node: Node) -> None:
"""Add a node in the target composition.
@ -209,9 +228,20 @@ class CompositeNode(Node):
msg,
)
self._compositions[target_composition].append(node)
# The node index is position in the composition regardless of the type of the node
node.index = self._node_counter + 1
self._node_counter += 1
def calculate_indices(self):
"""
Calculate the indices of all nodes based on their composition type.
This is only called when needed.
"""
current_index = 1
for comp_type in self._supported_compositions:
for node in self._compositions[comp_type]:
node.index = current_index
current_index += 1
if isinstance(node, CompositeNode):
node.calculate_indices()
def get_nodes(self, target_composition: str) -> list:
"""Get a node from the compositions.
@ -321,47 +351,6 @@ class CompositeNode(Node):
return node_dict
class CompositeTasksNode(CompositeNode):
"""A special composite node which only support adding "tasks". Useful for block and role."""
def __init__(
self,
node_name: str,
node_id: str,
when: str = "",
raw_object: Any = None,
parent: "Node" = None,
index: int | None = None,
) -> None:
super().__init__(
node_name=node_name,
node_id=node_id,
when=when,
raw_object=raw_object,
parent=parent,
index=index,
)
self._supported_compositions = ["tasks"]
def add_node(self, target_composition: str, node: Node) -> None:
"""Override the add_node because the composite tasks only contains "tasks" regardless of the context
(pre_tasks or post_tasks).
:param target_composition: This is ignored.
:param node:
:return:
"""
super().add_node("tasks", node)
@property
def tasks(self) -> list[Node]:
"""The tasks attached to this block.
:return:
"""
return self.get_nodes("tasks")
class PlaybookNode(CompositeNode):
"""A playbook is a list of play."""
@ -371,17 +360,18 @@ class PlaybookNode(CompositeNode):
node_id: str | None = None,
when: str = "",
raw_object: Any = None,
index: int | None = None,
) -> None:
super().__init__(
node_name=node_name,
node_id=node_id or generate_id("playbook_"),
when=when,
raw_object=raw_object,
index=index,
supported_compositions=["plays"],
)
def display_name(self) -> str:
return self.name
def set_location(self) -> None:
"""Playbooks only have the path as position.
@ -441,12 +431,14 @@ class PlaybookNode(CompositeNode):
self,
exclude_empty_plays: bool = False,
exclude_plays_without_roles: bool = False,
include_handlers: bool = False,
**kwargs,
) -> dict:
"""Return a dictionary representation of this playbook.
:param exclude_empty_plays: Whether to exclude the empty plays from the result or not
:param exclude_plays_without_roles: Whether to exclude the plays that do not have roles
:param include_handlers: Whether to include the handlers in the output or not
:param kwargs:
:return:
"""
@ -458,7 +450,9 @@ class PlaybookNode(CompositeNode):
exclude_empty=exclude_empty_plays,
exclude_without_roles=exclude_plays_without_roles,
):
playbook_dict["plays"].append(play.to_dict(**kwargs))
playbook_dict["plays"].append(
play.to_dict(include_handlers=include_handlers, **kwargs)
)
return playbook_dict
@ -468,7 +462,8 @@ class PlayNode(CompositeNode):
- pre_tasks
- roles
- tasks
- post_tasks.
- post_tasks
- handlers
"""
def __init__(
@ -478,7 +473,6 @@ class PlayNode(CompositeNode):
when: str = "",
raw_object: Any = None,
parent: "Node" = None,
index: int | None = None,
hosts: list[str] | None = None,
) -> None:
"""
@ -493,12 +487,24 @@ class PlayNode(CompositeNode):
when=when,
raw_object=raw_object,
parent=parent,
index=index,
supported_compositions=["pre_tasks", "roles", "tasks", "post_tasks"],
supported_compositions=[
"pre_tasks",
"roles",
"tasks",
"post_tasks",
"handlers",
],
)
self.hosts = hosts or []
self.colors: tuple[str, str] = get_play_colors(self.id)
def display_name(self) -> str:
"""
Return the display name of the node.
:return:
"""
return f"Play: {self.name} ({len(self.hosts)})"
@property
def roles(self) -> list["RoleNode"]:
"""Return the roles of the plays. Tasks using "include_role" are NOT returned.
@ -519,20 +525,33 @@ class PlayNode(CompositeNode):
def tasks(self) -> list["Node"]:
return self.get_nodes("tasks")
def to_dict(self, **kwargs) -> dict:
@property
def handlers(self) -> list["TaskNode"]:
"""Return the handlers defined at the play level.
The handlers defined in roles are not included here.
:return:
"""
return self.get_nodes("handlers")
def to_dict(self, include_handlers: bool = False, **kwargs) -> dict:
"""Return a dictionary representation of this composite node. This representation is not meant to get the
original object back.
:return:
"""
data = super().to_dict(**kwargs)
data = super().to_dict(include_handlers=include_handlers, **kwargs)
data["hosts"] = self.hosts
data["colors"] = {"main": self.colors[0], "font": self.colors[1]}
if not include_handlers:
data["handlers"] = []
return data
class BlockNode(CompositeTasksNode):
class BlockNode(CompositeNode):
"""A block node: https://docs.ansible.com/ansible/latest/user_guide/playbooks_blocks.html."""
def __init__(
@ -542,7 +561,6 @@ class BlockNode(CompositeTasksNode):
when: str = "",
raw_object: Any = None,
parent: "Node" = None,
index: int | None = None,
) -> None:
super().__init__(
node_name=node_name,
@ -550,9 +568,26 @@ class BlockNode(CompositeTasksNode):
when=when,
raw_object=raw_object,
parent=parent,
index=index,
supported_compositions=["tasks"],
)
def add_node(self, target_composition: str, node: Node) -> None:
"""Block only supports adding tasks regardless of the context
:param target_composition: This is ignored.
:param node:
:return:
"""
super().add_node("tasks", node)
@property
def tasks(self) -> list[Node]:
"""The tasks attached to this block.
:return:
"""
return self.get_nodes("tasks")
class TaskNode(LoopMixin, Node):
"""A task node. This matches an Ansible Task."""
@ -564,7 +599,6 @@ class TaskNode(LoopMixin, Node):
when: str = "",
raw_object: Any = None,
parent: "Node" = None,
index: int | None = None,
) -> None:
""":param node_name:
:param node_id:
@ -576,11 +610,17 @@ class TaskNode(LoopMixin, Node):
when=when,
raw_object=raw_object,
parent=parent,
index=index,
)
def is_handler(self) -> bool:
"""Return true if this task is a handler, false otherwise.
class RoleNode(LoopMixin, CompositeTasksNode):
:return:
"""
return isinstance(self.raw_object, Handler) or self.id.startswith("handler_")
class RoleNode(LoopMixin, CompositeNode):
"""A role node. A role is a composition of tasks."""
def __init__(
@ -590,7 +630,6 @@ class RoleNode(LoopMixin, CompositeTasksNode):
when: str = "",
raw_object: Any = None,
parent: "Node" = None,
index: int | None = None,
include_role: bool = False,
) -> None:
"""
@ -606,9 +645,22 @@ class RoleNode(LoopMixin, CompositeTasksNode):
when=when,
raw_object=raw_object,
parent=parent,
index=index,
supported_compositions=["tasks", "handlers"],
)
def add_node(self, target_composition: str, node: Node) -> None:
"""Add a node in the target composition.
:param target_composition: The name of the target composition
:param node: The node to add in the given composition
:return:
"""
if target_composition != "handlers":
# If we are not adding a handler, we always add the node to the task composition
super().add_node("tasks", node)
else:
super().add_node("handlers", node)
def set_location(self) -> None:
"""Retrieve the position depending on whether it's an include_role or not.
@ -624,19 +676,41 @@ class RoleNode(LoopMixin, CompositeTasksNode):
def has_loop(self) -> bool:
if not self.include_role:
# Only include_role supports loop
# Only the include_role supports loop
return False
return super().has_loop()
def to_dict(self, **kwargs) -> dict:
def to_dict(self, include_handlers: bool = False, **kwargs) -> dict:
"""Return a dictionary representation of this composite node. This representation is not meant to get the
original object back.
:param include_handlers: Whether to include the handlers in the output or not
:param kwargs:
:return:
"""
node_dict = super().to_dict(**kwargs)
node_dict["include_role"] = self.include_role
if not include_handlers:
node_dict["handlers"] = []
return node_dict
@property
def tasks(self) -> list[Node]:
"""The tasks attached to this block.
:return:
"""
return self.get_nodes("tasks")
@property
def handlers(self) -> list["TaskNode"]:
"""Return the handlers defined in the role.
When parsing a role, the handlers are considered as tasks. This is just a convenient method to get the handlers
of a role.
:return:
"""
return self.get_nodes("handlers")

View file

@ -16,11 +16,12 @@ from abc import ABC, abstractmethod
from ansible.cli import CLI
from ansible.errors import AnsibleError, AnsibleParserError, AnsibleUndefinedVariable
from ansible.parsing.yaml.objects import AnsibleUnicode
from ansible.parsing.yaml.objects import AnsibleSequence, AnsibleUnicode
from ansible.playbook import Playbook
from ansible.playbook.block import Block
from ansible.playbook.helpers import load_list_of_blocks
from ansible.playbook.play import Play
from ansible.playbook.role import Role
from ansible.playbook.role_include import IncludeRole
from ansible.playbook.task import Task
from ansible.playbook.task_include import TaskInclude
@ -30,6 +31,7 @@ from ansible.utils.display import Display
from ansibleplaybookgrapher.graph_model import (
BlockNode,
CompositeNode,
Node,
PlaybookNode,
PlayNode,
RoleNode,
@ -99,7 +101,12 @@ class BaseParser(ABC):
node_type: str,
parent_node: CompositeNode,
) -> bool:
"""Include the task in the graph.
"""Add the task in the graph.
:param task: The task to include.
:param task_vars: The variables of the task.
:param node_type: The type of the node.
:param parent_node: The parent node.
:return: True if the task has been included, false otherwise.
"""
# Ansible-core 2.11 added an implicit meta-task at the end of the role. So wee skip it here.
@ -154,7 +161,7 @@ class PlaybookParser(BaseParser):
:param group_roles_by_name: Group roles by name instead of considering them as separate nodes with different IDs.
:param playbook_name: On optional name of the playbook to parse.
:param exclude_roles: Only add tasks whose roles do not match these values
:param only_roles: Ignore all task nodes when rendering graph
:param only_roles: Ignore all task nodes when rendering the graph.
It will be used as the node name if provided in replacement of the file name.
"""
super().__init__(tags=tags, skip_tags=skip_tags)
@ -168,14 +175,16 @@ class PlaybookParser(BaseParser):
def parse(self, *args, **kwargs) -> PlaybookNode:
"""Loop through the playbook and generate the graph.
The graph is drawn following this order (https://docs.ansible.com/ansible/2.4/playbooks_reuse_roles.html#using-roles)
The graph is parsed following this order (https://docs.ansible.com/ansible/2.4/playbooks_reuse_roles.html#using-roles)
for each play:
add pre_tasks
add roles
if include_role_tasks
add role_tasks
add role tasks
add role handlers
add tasks
add post_tasks
add handlers
:return:
"""
display.display(f"Parsing the playbook '{self.playbook_path}'")
@ -205,8 +214,7 @@ class PlaybookParser(BaseParser):
self.template(play.hosts, play_vars),
)
]
play_name = f"Play: {clean_name(play.get_name())} ({len(play_hosts)})"
play_name = self.template(play_name, play_vars)
play_name = self.template(clean_name(play.get_name()), play_vars)
display.v(f"Parsing {play_name}")
@ -241,7 +249,7 @@ class PlaybookParser(BaseParser):
if role.get_name() in self.exclude_roles:
continue
# the role object doesn't inherit the tags from the play. So we add it manually.
# The role object doesn't inherit the tags from the play. So we add it manually.
role.tags = role.tags + play.tags
# More context on this line, see here: https://github.com/ansible/ansible/issues/82310
@ -275,7 +283,7 @@ class PlaybookParser(BaseParser):
play_node.add_node("roles", role_node)
if self.include_role_tasks:
# loop through the tasks of the roles
# loop through the tasks
for block in role.compile(play):
self._include_tasks_in_blocks(
current_play=play,
@ -284,7 +292,17 @@ class PlaybookParser(BaseParser):
play_vars=play_vars,
node_type="task",
)
# end of the roles loop
# loop through the handlers of the roles
for block in role.get_handler_blocks(play):
self._include_tasks_in_blocks(
current_play=play,
parent_nodes=[role_node],
block=block,
play_vars=play_vars,
node_type="handler",
)
# end of the roles loop
# loop through the tasks
display.v("Parsing tasks...")
@ -307,6 +325,19 @@ class PlaybookParser(BaseParser):
play_vars=play_vars,
node_type="post_task",
)
# loop through the handlers of the play
for handler_block in play.get_handlers():
self._include_tasks_in_blocks(
current_play=play,
parent_nodes=[play_node],
block=handler_block,
play_vars=play_vars,
node_type="handler",
)
# TODO: Add handlers only only if they are notified AND after each section.
# add_handlers_in_notify(play_node)
# Summary
display.v(f"{len(play_node.pre_tasks)} pre_task(s) added to the graph.")
display.v(f"{len(play_node.roles)} role(s) added to the play")
@ -314,6 +345,7 @@ class PlaybookParser(BaseParser):
display.v(f"{len(play_node.post_tasks)} post_task(s) added to the play")
# moving to the next play
playbook_root_node.calculate_indices()
return playbook_root_node
def _include_tasks_in_blocks(
@ -326,7 +358,7 @@ class PlaybookParser(BaseParser):
) -> None:
"""Recursively read all the tasks of the block and add it to the graph.
:param parent_nodes: This is a list of parent nodes. Each time, we see an include_role, the corresponding node is
:param parent_nodes: This is the list of parent nodes. Each time, we see an include_role, the corresponding node is
added to this list
:param current_play:
:param block:
@ -527,3 +559,55 @@ class PlaybookParser(BaseParser):
node_type=node_type,
parent_node=parent_nodes[-1],
)
def add_handlers_in_notify(play_node: PlayNode):
"""
Add the handlers in the "notify" attribute of the tasks. This has to be done separately for the pre_tasks, tasks
and post_tasks because the handlers are not shared between them.
Handlers not used will not be kept in the graph.
The role handlers are managed separately.
:param play_node:
:return:
"""
_add_notified_handlers(play_node, "pre_tasks", play_node.pre_tasks)
_add_notified_handlers(play_node, "tasks", play_node.tasks)
_add_notified_handlers(play_node, "post_tasks", play_node.post_tasks)
def _add_notified_handlers(
play_node: PlayNode, target_composition: str, tasks: list[Node]
) -> list[str]:
"""Get the handlers that are notified by the tasks.
:param play_node: The list of the play handlers.
:param target_composition: The target composition to add the handlers.
:param tasks: The list of tasks.
:return:
"""
notified_handlers = []
play_handlers = play_node.handlers
for task_node in tasks:
task = task_node.raw_object
if task.notify:
if isinstance(task.notify, AnsibleUnicode):
notified_handlers.append(task.notify)
elif isinstance(task.notify, AnsibleSequence):
notified_handlers.extend(task.notify)
for p_handler in play_handlers:
if p_handler.name in notified_handlers:
play_node.add_node(
target_composition,
TaskNode(
p_handler.name,
node_id=generate_id("handler_"),
raw_object=p_handler.raw_object,
parent=p_handler.parent,
),
)
return notified_handlers

View file

@ -123,7 +123,6 @@ class PlaybookBuilder(ABC):
task_node=node,
color=color,
fontcolor=fontcolor,
node_label_prefix=kwargs.pop("node_label_prefix", ""),
**kwargs,
)
else:
@ -137,26 +136,35 @@ class PlaybookBuilder(ABC):
self,
hide_empty_plays: bool = False,
hide_plays_without_roles: bool = False,
show_handlers: 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 hide_plays_without_roles: Whether to hide plays without roles or not
:param show_handlers: Whether to show the handlers or not.
:param kwargs:
:return: The rendered playbook as a string.
"""
@abstractmethod
def build_play(self, play_node: PlayNode, **kwargs) -> None:
def build_play(
self, play_node: PlayNode, show_handlers: bool = False, **kwargs
) -> None:
"""Build a single play to be rendered
:param play_node:
:param play_node: The play to render
:param show_handlers: Whether to show the handlers or not.
:param kwargs:
:return:
"""
def traverse_play(self, play_node: PlayNode, **kwargs) -> None:
def traverse_play(
self, play_node: PlayNode, show_handlers: bool = False, **kwargs
) -> None:
"""Traverse a play to build the graph: pre_tasks, roles, tasks, post_tasks
:param play_node:
:param show_handlers: Whether to show the handlers or not.
:param kwargs:
:return:
"""
@ -167,7 +175,6 @@ class PlaybookBuilder(ABC):
node=pre_task,
color=color,
fontcolor=play_font_color,
node_label_prefix="[pre_task] ",
**kwargs,
)
@ -180,13 +187,21 @@ class PlaybookBuilder(ABC):
**kwargs,
)
if show_handlers:
for r_handler in role.handlers:
self.build_node(
node=r_handler,
color=color,
fontcolor=play_font_color,
**kwargs,
)
# tasks
for task in play_node.tasks:
self.build_node(
node=task,
color=color,
fontcolor=play_font_color,
node_label_prefix="[task] ",
**kwargs,
)
@ -196,10 +211,20 @@ class PlaybookBuilder(ABC):
node=post_task,
color=color,
fontcolor=play_font_color,
node_label_prefix="[post_task] ",
**kwargs,
)
if show_handlers:
# play handlers
for p_handler in play_node.handlers:
self.build_node(
node=p_handler,
color=color,
fontcolor=play_font_color,
node_label_prefix="[handler] ",
**kwargs,
)
@abstractmethod
def build_task(
self,

View file

@ -54,14 +54,17 @@ class GraphvizRenderer(Renderer):
view: bool = False,
hide_empty_plays: bool = False,
hide_plays_without_roles: bool = False,
show_handlers: bool = False,
**kwargs,
) -> str:
""":param open_protocol_handler: The protocol handler name to use
"""
: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
:param show_handlers:
:return: The path of the rendered file
"""
save_dot_file = kwargs.get("save_dot_file", False)
@ -85,6 +88,7 @@ class GraphvizRenderer(Renderer):
builder.build_playbook(
hide_empty_plays=hide_empty_plays,
hide_plays_without_roles=hide_plays_without_roles,
show_handlers=show_handlers,
)
roles_built.update(builder.roles_built)
@ -150,16 +154,25 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
"""
# Here we have a TaskNode
digraph = kwargs["digraph"]
node_label_prefix = kwargs["node_label_prefix"]
edge_label = f"{task_node.index} {task_node.when}"
edge_style = "solid"
node_shape = "rectangle"
node_style = "solid"
if task_node.is_handler():
edge_style = "dotted"
node_shape = "hexagon"
node_style = "dotted"
digraph.node(
task_node.id,
label=node_label_prefix + task_node.name,
shape="octagon",
label=task_node.display_name(),
shape=node_shape,
id=task_node.id,
tooltip=task_node.name,
color=color,
style=node_style,
URL=self.get_node_url(task_node),
)
@ -173,6 +186,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
id=f"edge_{task_node.index}_{task_node.parent.id}_{task_node.id}",
tooltip=edge_label,
labeltooltip=edge_label,
style=edge_style,
)
def build_block(
@ -205,7 +219,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
# block node
cluster_block_subgraph.node(
block_node.id,
label=f"[block] {block_node.name}",
label=block_node.display_name(),
shape="box",
style="filled",
id=block_node.id,
@ -234,6 +248,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
**kwargs,
) -> None:
"""Render a role in the graph
:return:
"""
digraph = kwargs["digraph"]
@ -257,10 +272,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
self.roles_built.add(role_node)
if role_node.include_role: # For include_role, we point to a file
url = self.get_node_url(role_node)
else: # For normal role invocation, we point to the folder
url = self.get_node_url(role_node)
url = self.get_node_url(role_node)
plays_using_this_role = self.roles_usage[role_node]
if len(plays_using_this_role) > 1:
@ -274,7 +286,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
role_subgraph.node(
role_node.id,
id=role_node.id,
label=f"[role] {role_node.name}",
label=role_node.display_name(),
style="filled",
tooltip=role_node.name,
fontcolor=fontcolor,
@ -294,11 +306,13 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
self,
hide_empty_plays: bool = False,
hide_plays_without_roles: bool = False,
show_handlers: bool = 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
:param show_handlers: Whether to show the handlers or not.
:return: The text representation of the graphviz dot format for the playbook.
"""
display.vvv("Converting the graph to the dot format for graphviz")
@ -316,12 +330,19 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
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)
self.build_play(
play, digraph=play_subgraph, show_handlers=show_handlers, **kwargs
)
return self.digraph.source
def build_play(self, play_node: PlayNode, **kwargs) -> None:
""":param play_node:
def build_play(
self, play_node: PlayNode, show_handlers: bool = False, **kwargs
) -> None:
"""
:param show_handlers:
:param play_node:
:param kwargs:
:return:
"""
@ -336,7 +357,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
digraph.node(
play_node.id,
id=play_node.id,
label=play_node.name,
label=play_node.display_name(),
style="filled",
shape="box",
color=color,
@ -346,7 +367,7 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
)
# from playbook to play
playbook_to_play_label = f"{play_node.index} {play_node.name}"
playbook_to_play_label = f"{play_node.index}"
self.digraph.edge(
self.playbook_node.id,
play_node.id,
@ -359,4 +380,4 @@ class GraphvizPlaybookBuilder(PlaybookBuilder):
)
# traverse the play
self.traverse_play(play_node, **kwargs)
self.traverse_play(play_node, show_handlers=show_handlers, **kwargs)

View file

@ -44,6 +44,7 @@ class JSONRenderer(Renderer):
view: bool = False,
hide_empty_plays: bool = False,
hide_plays_without_roles: bool = False,
show_handlers: bool = False,
**kwargs,
) -> str:
playbooks = []
@ -53,6 +54,7 @@ class JSONRenderer(Renderer):
json_builder.build_playbook(
hide_empty_plays=hide_empty_plays,
hide_plays_without_roles=hide_plays_without_roles,
show_handlers=show_handlers,
)
playbooks.append(json_builder.json_output)
@ -90,12 +92,14 @@ class JSONPlaybookBuilder(PlaybookBuilder):
self,
hide_empty_plays: bool = False,
hide_plays_without_roles: bool = False,
show_handlers: bool = False,
**kwargs,
) -> str:
"""Build a playbook.
:param hide_empty_plays:
:param hide_plays_without_roles:
:param show_handlers: Whether to show handlers or not
:param kwargs:
:return:
"""
@ -106,13 +110,17 @@ class JSONPlaybookBuilder(PlaybookBuilder):
self.json_output = self.playbook_node.to_dict(
exclude_empty_plays=hide_empty_plays,
exclude_plays_without_roles=hide_plays_without_roles,
include_handlers=show_handlers,
)
return json.dumps(self.json_output)
def build_play(self, play_node: PlayNode, **kwargs) -> None:
def build_play(
self, play_node: PlayNode, show_handlers: bool = False, **kwargs
) -> None:
"""Not needed.
:param show_handlers:
:param play_node:
:param kwargs:
:return:

View file

@ -47,20 +47,22 @@ class MermaidFlowChartRenderer(Renderer):
view: bool = False,
hide_empty_plays: bool = False,
hide_plays_without_roles: bool = False,
show_handlers: bool = False,
directive: str = DEFAULT_DIRECTIVE,
orientation: str = DEFAULT_ORIENTATION,
**kwargs,
) -> str:
"""Render the graph to a Mermaid flow chart format.
: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 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 show_handlers: Whether to show handlers or not.
:param directive: Mermaid directive.
:param orientation: Mermaid graph orientation.
:param kwargs:
:return:
"""
@ -95,6 +97,7 @@ class MermaidFlowChartRenderer(Renderer):
mermaid_code += playbook_builder.build_playbook(
hide_empty_plays=hide_empty_plays,
hide_plays_without_roles=hide_plays_without_roles,
show_handlers=show_handlers,
)
link_order = playbook_builder.link_order
roles_built.update(playbook_builder.roles_built)
@ -173,6 +176,7 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
self,
hide_empty_plays: bool = False,
hide_plays_without_roles: bool = False,
show_handlers: bool = False,
**kwargs,
) -> str:
"""Build a playbook.
@ -180,42 +184,54 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
: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 show_handlers: Whether to show handlers or not
:param kwargs:
:return:
"""
display.vvv(
f"Converting the playbook '{self.playbook_node.name}' to mermaid format",
f"Converting the playbook '{self.playbook_node.display_name()}' to mermaid format",
)
# Playbook node
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.add_comment(f"Start of the playbook '{self.playbook_node.display_name()}'")
self.add_node(
node_id=self.playbook_node.id,
shape="rounded",
label=f"{self.playbook_node.display_name()}",
)
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.build_play(play_node, show_handlers=show_handlers, **kwargs)
self._indentation_level -= 1
self.add_comment(f"End of the playbook '{self.playbook_node.name}'\n")
self.add_comment(f"End of the playbook '{self.playbook_node.display_name()}'\n")
return self.mermaid_code
def build_play(self, play_node: PlayNode, **kwargs) -> None:
def build_play(
self, play_node: PlayNode, show_handlers: bool = False, **kwargs
) -> None:
"""Build a play.
:param show_handlers:
:param play_node:
:param kwargs:
:return:
"""
# Play node
color, play_font_color = play_node.colors
self.add_comment(f"Start of the play '{play_node.name}'")
self.add_comment(f"Start of the play '{play_node.display_name()}'")
self.add_text(f'{play_node.id}["{play_node.name}"]')
self.add_text(f"style {play_node.id} fill:{color},color:{play_font_color}")
self.add_node(
node_id=play_node.id,
shape="rect",
label=f"{play_node.display_name()}",
style=f"stroke:{color},fill:{color},color:{play_font_color}",
)
# From playbook to play
self.add_link(
@ -227,10 +243,10 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
# traverse the play
self._indentation_level += 1
self.traverse_play(play_node)
self.traverse_play(play_node, show_handlers, **kwargs)
self._indentation_level -= 1
self.add_comment(f"End of the play '{play_node.name}'")
self.add_comment(f"End of the play '{play_node.display_name()}'")
def build_task(
self,
@ -247,11 +263,24 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
:param kwargs:
:return:
"""
node_label_prefix = kwargs.get("node_label_prefix", "")
link_type = "--"
node_shape = "rect"
style = f"stroke:{color},fill:{fontcolor}"
if task_node.is_handler():
# dotted style for handlers
link_type = "-.-"
node_shape = "hexagon"
style += ",stroke-dasharray: 2, 2"
# Task node
self.add_text(f'{task_node.id}["{node_label_prefix} {task_node.name}"]')
self.add_text(f"style {task_node.id} stroke:{color},fill:{fontcolor}")
self.add_node(
node_id=task_node.id,
shape=node_shape,
label=task_node.display_name(),
style=style,
)
# From parent to task
self.add_link(
@ -259,8 +288,36 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
text=f"{task_node.index} {task_node.when}",
dest_id=task_node.id,
style=f"stroke:{color},color:{color}",
link_type=link_type,
)
def add_node(self, node_id: str, shape: str, label: str, style: str = "") -> None:
"""Add a node to the mermaid code.
:param node_id: The node id.
:param shape: The shape of the node.
:param label: The label of the node.
:param style: The style of the node.
:return:
"""
# To ensure backward compatibility with older versions of Mermaid, I'm still using the old syntax of defining the
# shape and label of the node.
# This method takes the shape name which is converted to the corresponding shape using the old syntax.
# See https://mermaid.js.org/syntax/flowchart.html#expanded-node-shapes-in-mermaid-flowcharts-v11-3-0
# Once Gitlab updates to mermaid >= 11.3.0 (https://gitlab.com/gitlab-org/gitlab/-/issues/491514), we can use the new syntax.
label = label.strip()
shapes_mapping = {
"rect": f'{node_id}["{label}"]',
"hexagon": f'{node_id}{{{{"{label}"}}}}',
"rounded": f'{node_id}("{label}")',
"stadium": f'{node_id}(["{label}"])',
}
self.add_text(shapes_mapping[shape])
if style.strip() != "":
self.add_text(f"style {node_id} {style}")
def build_role(
self,
role_node: RoleNode,
@ -276,7 +333,7 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
:param kwargs:
:return:
"""
self.add_comment(f"Start of the role '{role_node.name}'")
self.add_comment(f"Start of the role '{role_node.display_name()}'")
plays_using_this_role = len(self.roles_usage[role_node])
node_color = color
@ -299,9 +356,11 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
self.roles_built.add(role_node)
# Role node
self.add_text(f'{role_node.id}(["[role] {role_node.name}"])')
self.add_text(
f"style {role_node.id} fill:{node_color},color:{fontcolor},stroke:{node_color}",
self.add_node(
node_id=role_node.id,
shape="stadium",
label=role_node.display_name(),
style=f"fill:{node_color},color:{fontcolor},stroke:{node_color}",
)
# Role tasks
@ -314,7 +373,7 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
)
self._indentation_level -= 1
self.add_comment(f"End of the role '{role_node.name}'")
self.add_comment(f"End of the role '{role_node.display_name()}'")
def build_block(
self,
@ -333,9 +392,11 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
"""
# Block node
self.add_comment(f"Start of the block '{block_node.name}'")
self.add_text(f'{block_node.id}["[block] {block_node.name}"]')
self.add_text(
f"style {block_node.id} fill:{color},color:{fontcolor},stroke:{color}",
self.add_node(
node_id=block_node.id,
shape="rect",
label=block_node.display_name(),
style=f"fill:{color},color:{fontcolor},stroke:{color}",
)
# from parent to block
@ -368,13 +429,13 @@ class MermaidFlowChartPlaybookBuilder(PlaybookBuilder):
style: str = "",
link_type: str = "--",
) -> None:
"""Add link between two nodes.
"""Add the link between two nodes.
:param source_id: The link source
:param text: The text on the link
:param dest_id: The link destination
:param style: The style to apply to the link
:param link_type: Type of link to create. https://mermaid.js.org/syntax/flowchart.html#links-between-nodes
:param source_id: The link source.
:param text: The text on the link.
:param dest_id: The link destination.
:param style: The style to apply to the link.
:param link_type: Type of link to create. https://mermaid.js.org/syntax/flowchart.html#links-between-nodes.
:return:
"""
# Replace double quotes with single quotes. Mermaid doesn't like double quotes

View file

@ -1,4 +1,4 @@
# Copyright (C) 2022 Mohamed El Mouctar HAIDARA
# Copyright (C) 2024 Mohamed El Mouctar HAIDARA
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View file

@ -15,7 +15,9 @@ target-version = "py310"
[lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
select = ["E4", "E7", "E9", "F", "I", "RUF", "PTH", "ANN001", "PT", "W293"]
ignore = []
ignore = [
"F401" # Ignore unused imports because of this https://github.com/astral-sh/ruff/issues/1619
]
# Allow fix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]

19
tests/fixtures/handlers-in-role.yml vendored Normal file
View file

@ -0,0 +1,19 @@
- name: handler in role
hosts: localhost
gather_facts: false
pre_tasks:
- name: My pre task debug
debug: msg="pre task"
roles:
- role: role-with-handlers
post_tasks:
- name: My post task debug
debug: msg="post task"
changed_when: true
notify: restart postgres
handlers:
- name: restart postgres
assert: { that: true }

60
tests/fixtures/handlers.yml vendored Normal file
View file

@ -0,0 +1,60 @@
- name: play 1 - handlers
hosts: localhost
gather_facts: false
pre_tasks:
- name: My debug pre task
debug: msg="pre task"
changed_when: true
notify:
- restart mysql in the pre_tasks
- restart nginx
tasks:
- name: foo
assert: { that: true }
changed_when: true
notify: restart mysql
- name: bar
assert: { that: true }
changed_when: true
notify: restart nginx
handlers:
- name: restart nginx
assert: { that: true }
- name: restart mysql
assert: { that: true }
- name: restart mysql in the pre_tasks
assert: { that: true }
- name: play 2 - handlers with meta
hosts: localhost
gather_facts: false
tasks:
- name: foo
assert: { that: true }
changed_when: true
notify: restart postgres
- name: Debug
debug: msg="debug"
- name: Flush handlers (meta)
meta: flush_handlers
- name: bar
assert: { that: true }
changed_when: true
notify: stop traefik
handlers:
- name: restart postgres
assert: { that: true }
- name: stop traefik
assert: { that: true }
- name: restart apache
assert: { that: true }

View file

@ -103,6 +103,12 @@
"items": {
"$ref": "#/$defs/task"
}
},
"handlers": {
"type": "array",
"items": {
"$ref": "#/$defs/task"
}
}
},
"required": [
@ -148,7 +154,7 @@
"type": "string"
},
"id": {
"pattern": "^(pre_task|task|post_task|role|block)_.+$",
"pattern": "^(pre_task|task|post_task|role|block|handler)_.+$",
"type": "string"
},
"name": {

View file

@ -0,0 +1,3 @@
---
- name: restart postgres from the role
assert: { that: true }

View file

@ -0,0 +1,5 @@
---
- name: Debug 1
debug: msg="My role with a handler"
notify: restart postgres from the role
changed_when: true

View file

@ -241,6 +241,30 @@ def test_cli_include_role_tasks(
assert cli.options.include_role_tasks == expected
@pytest.mark.parametrize(
("show_handlers_option", "expected"),
[(["--"], False), (["--show-handlers"], True)],
ids=["default", "include"],
)
def test_cli_show_handlers(
show_handlers_option: list[str],
expected: bool,
) -> None:
"""Test for show handlers options: --show-handlers
:param show_handlers_option:
:param expected:
:return:
"""
args = [__prog__, *show_handlers_option, "playboook.yml"]
cli = PlaybookGrapherCLI(args)
cli.parse()
assert cli.options.show_handlers == expected
@pytest.mark.parametrize(
("tags_option", "expected"),
[

View file

@ -113,6 +113,8 @@ def test_to_dict() -> None:
play.add_node("post_tasks", TaskNode("task 2"))
playbook.add_node("plays", play)
playbook.calculate_indices()
dict_rep = playbook.to_dict(exclude_empty_plays=True)
assert dict_rep["type"] == "PlaybookNode"

View file

@ -80,6 +80,7 @@ def _common_tests(
roles_number: int = 0,
pre_tasks_number: int = 0,
blocks_number: int = 0,
handlers_number: int = 0,
) -> dict[str, list[Element]]:
"""Perform some common tests on the generated svg file:
- Existence of svg file
@ -91,6 +92,7 @@ def _common_tests(
:param roles_number: Number of roles in the playbook
:param tasks_number: Number of tasks in the playbook
:param post_tasks_number: Number of post tasks in the playbook
:param handlers_number: Number of handlers in the playbook
:return: A dictionary with the different tasks, roles, pre_tasks as keys and a list of Elements (nodes) as values.
"""
# test if the file exists. It will exist only if we write in it.
@ -106,6 +108,7 @@ def _common_tests(
pre_tasks = pq("g[id^='pre_task_']")
blocks = pq("g[id^='block_']")
roles = pq("g[id^='role_']")
handlers = pq("g[id^='handler_']")
playbooks_file_names = [e.text for e in playbooks.find("text")]
assert (
@ -138,7 +141,11 @@ def _common_tests(
assert (
len(blocks) == blocks_number
), f"The graph '{svg_filename}' should contains {blocks_number} blocks(s) but we found {len(blocks)} blocks "
), f"The graph '{svg_filename}' should contains {blocks_number} blocks(s) but we found {len(blocks)} blocks"
assert (
len(handlers) == handlers_number
), f"The graph '{svg_filename}' should contains {handlers_number} handlers(s) but we found {len(handlers)} handlers "
return {
"tasks": tasks,
@ -147,6 +154,7 @@ def _common_tests(
"pre_tasks": pre_tasks,
"roles": roles,
"blocks": blocks,
"handlers": handlers,
}
@ -702,3 +710,68 @@ def test_graphing_a_playbook_in_a_collection(
roles_number=2,
tasks_number=6,
)
@pytest.mark.parametrize(
("flag", "handlers_number"),
[("--", 0), ("--show-handlers", 6)],
ids=["no_handlers", "show_handlers"],
)
def test_handlers(
request: pytest.FixtureRequest,
flag: str,
handlers_number: int,
) -> None:
"""Test graphing a playbook with handlers
:param request:
:return:
"""
svg_path, playbook_paths = run_grapher(
["handlers.yml"], output_filename=request.node.name, additional_args=[flag]
)
_common_tests(
svg_filename=svg_path,
playbook_paths=playbook_paths,
pre_tasks_number=1,
plays_number=2,
tasks_number=6,
handlers_number=handlers_number,
)
@pytest.mark.parametrize(
("flag", "handlers_number"),
[("--", 0), ("--show-handlers", 2)],
ids=["no_handlers", "show_handlers"],
)
def test_handlers_in_role(
request: pytest.FixtureRequest,
flag: str,
handlers_number: int,
) -> None:
"""Test graphing a playbook with handlers
:param request:
:return:
"""
svg_path, playbook_paths = run_grapher(
["handlers-in-role.yml"],
output_filename=request.node.name,
additional_args=[
"--include-role-tasks",
flag,
],
)
_common_tests(
svg_filename=svg_path,
playbook_paths=playbook_paths,
pre_tasks_number=1,
plays_number=1,
tasks_number=1,
post_tasks_number=1,
roles_number=1,
handlers_number=handlers_number,
)

View file

@ -61,8 +61,9 @@ def _common_tests(
roles_number: int = 0,
pre_tasks_number: int = 0,
blocks_number: int = 0,
handlers_number: int = 0,
) -> dict:
"""Do some checks on the generated json files.
"""Do some checks on the generated JSON files.
We are using JQ to avoid traversing the JSON ourselves (much easier).
:param json_path:
@ -75,7 +76,7 @@ def _common_tests(
schema = json.load(schema_file)
# If no exception is raised by validate(), the instance is valid.
# I currently don't use format but added it here to not forget to add in case I use in the future.
# I currently don't use format but added it here to not forget to add it in case I use in the future.
validate(
instance=output,
schema=schema,
@ -130,6 +131,14 @@ def _common_tests(
.all()
)
handlers = (
jq.compile(
'.. | objects | select(.type == "TaskNode" and (.id | startswith("handler_")))',
)
.input(output)
.all()
)
assert (
len(playbooks) == playbooks_number
), f"The file '{json_path}' should contains {playbooks_number} playbook(s) but we found {len(playbooks)} playbook(s)"
@ -158,6 +167,10 @@ def _common_tests(
len(blocks) == blocks_number
), f"The file '{json_path}' should contains {blocks_number} block(s) but we found {len(blocks)} blocks"
assert (
len(handlers) == handlers_number
), f"The file '{json_path}' should contains {handlers_number} handler(s) but we found {len(handlers)} handlers"
# Check the play
for play in plays:
assert (
@ -171,6 +184,7 @@ def _common_tests(
"pre_tasks": pre_tasks,
"roles": roles,
"blocks": blocks,
"handlers": handlers,
}
@ -262,3 +276,68 @@ def test_multi_playbooks(request: pytest.FixtureRequest) -> None:
tasks_number=35,
post_tasks_number=4,
)
@pytest.mark.parametrize(
("flag", "handlers_number"),
[("--", 0), ("--show-handlers", 6)],
ids=["no_handlers", "show_handlers"],
)
def test_handlers(
request: pytest.FixtureRequest, flag: str, handlers_number: int
) -> None:
"""Test for handlers.
:param request:
:return:"""
json_path, playbook_paths = run_grapher(
["handlers.yml"],
output_filename=request.node.name,
additional_args=[
"-i",
str(INVENTORY_PATH),
"--include-role-tasks",
flag,
],
)
_common_tests(
json_path,
plays_number=2,
pre_tasks_number=1,
tasks_number=6,
handlers_number=handlers_number,
)
@pytest.mark.parametrize(
("flag", "handlers_number"),
[("--", 0), ("--show-handlers", 2)],
ids=["no_handlers", "show_handlers"],
)
def test_handler_in_a_role(
request: pytest.FixtureRequest, flag: str, handlers_number: int
) -> None:
"""Test for handlers in the role.
:param request:
:return:
"""
json_path, playbook_paths = run_grapher(
["handlers-in-role.yml"],
output_filename=request.node.name,
additional_args=[
"-i",
str(INVENTORY_PATH),
"--include-role-tasks",
flag,
],
)
_common_tests(
json_path,
plays_number=1,
pre_tasks_number=1,
post_tasks_number=1,
tasks_number=1,
handlers_number=handlers_number,
roles_number=1,
)

View file

@ -99,6 +99,8 @@ def _common_tests(mermaid_file_path: str, playbook_paths: list[str], **kwargs) -
"with_block.yml",
"with_roles.yml",
"haidaram.test_collection.test",
"handlers.yml",
"handlers-in-role.yml",
],
)
def test_single_playbook(request: pytest.FixtureRequest, playbook: str) -> None:

View file

@ -580,3 +580,74 @@ def test_parsing_playbook_in_collection(
assert (
len(all_tasks) == 4 + 2
), "There should be 6 tasks in the playbook: 4 from the roles and 2 from the tasks at the playbook level"
@pytest.mark.parametrize("grapher_cli", [["handlers.yml"]], indirect=True)
def test_parsing_of_handlers(grapher_cli: PlaybookGrapherCLI) -> None:
"""Test if we are able to get the handlers in each play and add them in the graph
:return:
"""
parser = PlaybookParser(grapher_cli.options.playbooks[0])
playbook_node = parser.parse()
plays = playbook_node.plays()
assert len(plays) == 2
play_1, play_2 = playbook_node.plays()[0], playbook_node.plays()[1]
assert len(play_1.pre_tasks) == 1, "The first play should have 1 pre_tasks"
assert len(play_1.tasks) == 2, "The first play should have 2 tasks"
play_1_expected_handlers = [
"restart nginx",
"restart mysql",
"restart mysql in the pre_tasks",
]
assert len(play_1.handlers) == len(play_1_expected_handlers)
for idx, h in enumerate(play_1.handlers):
assert (
h.name == play_1_expected_handlers[idx]
), f"The handler should be '{play_1_expected_handlers[idx]}'"
assert h.is_handler()
# Second play
assert len(play_2.tasks) == 4, "The second play should have 6 tasks"
play_1_expected_handler = [
"restart postgres",
"stop traefik",
"restart apache",
]
assert len(play_2.handlers) == len(play_1_expected_handler)
for idx, h in enumerate(play_2.handlers):
assert (
h.name == play_1_expected_handler[idx]
), f"The handler should be '{play_1_expected_handler[idx]}'"
assert h.is_handler()
assert h.location is not None
@pytest.mark.parametrize("grapher_cli", [["handlers-in-role.yml"]], indirect=True)
def test_parsing_handler_in_role(grapher_cli: PlaybookGrapherCLI) -> None:
"""Test if we are able to get the handlers defined in a role and add them in the graph
:return:
"""
parser = PlaybookParser(grapher_cli.options.playbooks[0], include_role_tasks=True)
playbook_node = parser.parse()
plays = playbook_node.plays()
assert len(plays) == 1
play = plays[0]
assert len(play.handlers) == 1, "The play should have 1 handler"
handler = play.handlers[0]
assert handler.name == "restart postgres"
assert len(play.roles) == 1, "The play should have 1 role"
role = play.roles[0]
assert len(role.tasks) == 1, "The role should have 1 task"
assert len(role.handlers) == 1, "The role should have 1 handler"
assert role.handlers[0].name == f"{role.name} : restart postgres from the role"
assert role.handlers[0].location is not None
assert (
len(set(play.handlers + role.handlers)) == 2
), "The total number of handlers should be 2"