mirror of
https://github.com/haidaraM/ansible-playbook-grapher
synced 2025-02-20 05:28:24 +00:00
feat: Add initial support for handlers (#217)
Related #214 - 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:
parent
5ccfed2ea3
commit
df7be04e6a
21 changed files with 806 additions and 160 deletions
README.md
ansibleplaybookgrapher
ruff.tomltests
fixtures
test_cli.pytest_graph_model.pytest_graphviz_renderer.pytest_json_renderer.pytest_mermaid_renderer.pytest_parser.py
27
README.md
27
README.md
|
@ -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
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
19
tests/fixtures/handlers-in-role.yml
vendored
Normal 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
60
tests/fixtures/handlers.yml
vendored
Normal 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 }
|
8
tests/fixtures/json-schemas/v1.json
vendored
8
tests/fixtures/json-schemas/v1.json
vendored
|
@ -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": {
|
||||
|
|
3
tests/fixtures/roles/role-with-handlers/handlers/main.yml
vendored
Normal file
3
tests/fixtures/roles/role-with-handlers/handlers/main.yml
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
---
|
||||
- name: restart postgres from the role
|
||||
assert: { that: true }
|
5
tests/fixtures/roles/role-with-handlers/tasks/main.yml
vendored
Normal file
5
tests/fixtures/roles/role-with-handlers/tasks/main.yml
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
- name: Debug 1
|
||||
debug: msg="My role with a handler"
|
||||
notify: restart postgres from the role
|
||||
changed_when: true
|
|
@ -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"),
|
||||
[
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue