mirror of
https://github.com/haidaraM/ansible-playbook-grapher
synced 2024-11-10 06:04:15 +00:00
feat: Add a flag to group roles by name - Revert the old behavior (#122)
This commit is contained in:
parent
e7f23daee7
commit
75f26a9ea2
10 changed files with 168 additions and 62 deletions
65
README.md
65
README.md
|
@ -81,48 +81,73 @@ regarding the blocks.
|
|||
The available options:
|
||||
|
||||
```
|
||||
ansible-playbook-grapher --help
|
||||
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] [--version]
|
||||
[-t TAGS] [--skip-tags SKIP_TAGS] [--vault-id VAULT_IDS]
|
||||
[--ask-vault-password | --vault-password-file VAULT_PASSWORD_FILES] [-e EXTRA_VARS]
|
||||
playbook
|
||||
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] [--version] [-t TAGS]
|
||||
[--skip-tags SKIP_TAGS] [--vault-id VAULT_IDS]
|
||||
[--ask-vault-password | --vault-password-file VAULT_PASSWORD_FILES]
|
||||
[-e EXTRA_VARS]
|
||||
playbooks [playbooks ...]
|
||||
|
||||
Make graphs from your Ansible Playbooks.
|
||||
|
||||
positional arguments:
|
||||
playbook Playbook to graph
|
||||
playbooks Playbook(s) to graph
|
||||
|
||||
optional arguments:
|
||||
--ask-vault-password, --ask-vault-pass
|
||||
ask for vault password
|
||||
--group-roles-by-name
|
||||
When rendering the graph, only a single role will be
|
||||
display for all roles having the same names.
|
||||
--include-role-tasks Include the tasks of the role in the graph.
|
||||
--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 (tasks) in vscode, set this to '{"file": "vscode://file/{path}:{line}:{column}", "folder": "{path}"}'
|
||||
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
|
||||
(tasks) in vscode, set this to '{"file":
|
||||
"vscode://file/{path}:{line}:{column}", "folder":
|
||||
"{path}"}'
|
||||
--open-protocol-handler {default,vscode,custom}
|
||||
The protocol to use to open the nodes when double-clicking on them in your SVG viewer. Your SVG viewer must support double-click
|
||||
and Javascript. The supported values are 'default', 'vscode' and 'custom'. For 'default', the URL will be the path to the file or
|
||||
folders. When using a browser, it will open or download them. For 'vscode', the folders and files will be open with VSCode. For
|
||||
'custom', you need to set a custom format with --open-protocol-custom-formats.
|
||||
The protocol to use to open the nodes when double-
|
||||
clicking on them in your SVG viewer. Your SVG viewer
|
||||
must support double-click and Javascript. The
|
||||
supported values are 'default', 'vscode' and 'custom'.
|
||||
For 'default', the URL will be the path to the file or
|
||||
folders. When using a browser, it will open or
|
||||
download them. For 'vscode', the folders and files
|
||||
will be open with VSCode. For 'custom', you need to
|
||||
set a custom format with --open-protocol-custom-
|
||||
formats.
|
||||
--skip-tags SKIP_TAGS
|
||||
only run plays and tasks whose tags do not match these values
|
||||
only run plays and tasks whose tags do not match these
|
||||
values
|
||||
--vault-id VAULT_IDS the vault identity to use
|
||||
--vault-password-file VAULT_PASSWORD_FILES, --vault-pass-file VAULT_PASSWORD_FILES
|
||||
vault password file
|
||||
--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
|
||||
--view Automatically open the resulting SVG file with your
|
||||
system’s default viewer application for the file type
|
||||
-e EXTRA_VARS, --extra-vars EXTRA_VARS
|
||||
set additional variables as key=value or YAML/JSON, if filename prepend with @
|
||||
set additional variables as key=value or YAML/JSON, if
|
||||
filename prepend with @
|
||||
-h, --help show this help message and exit
|
||||
-i INVENTORY, --inventory INVENTORY
|
||||
specify inventory host path or comma separated host list.
|
||||
specify inventory host path or comma separated host
|
||||
list.
|
||||
-o OUTPUT_FILENAME, --output-file-name OUTPUT_FILENAME
|
||||
Output filename without the '.svg' extension. Default: <playbook>.svg
|
||||
Output filename without the '.svg' extension. Default:
|
||||
<playbook>.svg
|
||||
-s, --save-dot-file Save the dot file used to generate the graph.
|
||||
-t TAGS, --tags TAGS only run plays and tasks tagged with these values
|
||||
-v, --verbose verbose mode (-vvv for more, -vvvv to enable connection debugging)
|
||||
-v, --verbose verbose mode (-vvv for more, -vvvv to enable
|
||||
connection debugging)
|
||||
|
||||
```
|
||||
|
||||
## Configuration: ansible.cfg
|
||||
|
|
|
@ -62,6 +62,7 @@ class GrapherCLI(CLI, ABC):
|
|||
include_role_tasks=self.options.include_role_tasks,
|
||||
tags=self.options.tags,
|
||||
skip_tags=self.options.skip_tags,
|
||||
group_roles_by_name=self.options.group_roles_by_name,
|
||||
)
|
||||
digraph = grapher.graph(
|
||||
open_protocol_handler=self.options.open_protocol_handler,
|
||||
|
@ -178,6 +179,13 @@ class PlaybookGrapherCLI(GrapherCLI):
|
|||
""",
|
||||
)
|
||||
|
||||
self.parser.add_argument(
|
||||
"--group-roles-by-name",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="When rendering the graph, only a single role will be display for all roles having the same names.",
|
||||
)
|
||||
|
||||
self.parser.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
|
|
|
@ -251,7 +251,6 @@ class PlaybookNode(CompositeNode):
|
|||
def roles_usage(self) -> Dict["RoleNode", List[Node]]:
|
||||
"""
|
||||
For each role in the graph, return the plays that reference the role
|
||||
FIXME: Review this implementation. It may not be the most efficient way, but it's ok for the moment
|
||||
:return: A dict with key as role node and value the list of plays
|
||||
"""
|
||||
|
||||
|
|
|
@ -63,12 +63,14 @@ class Grapher:
|
|||
include_role_tasks: bool = False,
|
||||
tags: List[str] = None,
|
||||
skip_tags: List[str] = None,
|
||||
group_roles_by_name: bool = False,
|
||||
):
|
||||
"""
|
||||
Parses all the provided playbooks
|
||||
:param include_role_tasks: Should we include the role tasks
|
||||
:param tags: Only add plays and tasks tagged with these values
|
||||
:param skip_tags: Only add plays and tasks whose tags do not match these values
|
||||
:param group_roles_by_name: Group roles by name instead of considering them as separate nodes with different IDs
|
||||
:return:
|
||||
"""
|
||||
|
||||
|
@ -79,6 +81,7 @@ class Grapher:
|
|||
skip_tags=skip_tags,
|
||||
playbook_filename=playbook_file,
|
||||
include_role_tasks=include_role_tasks,
|
||||
group_roles_by_name=group_roles_by_name,
|
||||
)
|
||||
playbook_node = parser.parse()
|
||||
self.playbook_nodes.append(playbook_node)
|
||||
|
@ -334,7 +337,7 @@ class GraphvizGraphBuilder:
|
|||
)
|
||||
|
||||
# check if we already built this role
|
||||
role_to_render = self.roles_built.get(destination.name, None)
|
||||
role_to_render = self.roles_built.get(destination.id, None)
|
||||
if role_to_render is None:
|
||||
# Merge the colors for each play where this role is used
|
||||
role_plays = self.roles_usage[destination]
|
||||
|
@ -346,7 +349,7 @@ class GraphvizGraphBuilder:
|
|||
colors = list(map(self.play_colors.get, role_plays))[0]
|
||||
role_color = colors[0]
|
||||
|
||||
self.roles_built[destination.name] = destination
|
||||
self.roles_built[destination.id] = destination
|
||||
|
||||
with graph.subgraph(name=destination.name, node_attr={}) as role_subgraph:
|
||||
role_subgraph.node(
|
||||
|
|
|
@ -136,16 +136,18 @@ class PlaybookParser(BaseParser):
|
|||
include_role_tasks=False,
|
||||
tags: List[str] = None,
|
||||
skip_tags: List[str] = None,
|
||||
group_roles_by_name: bool = False,
|
||||
):
|
||||
"""
|
||||
:param playbook_filename: The filename of the playbook to parse
|
||||
:param include_role_tasks: If true, the tasks of the role will be included in the graph
|
||||
:param tags: Only add plays and tasks tagged with these values
|
||||
:param skip_tags: Only add plays and tasks whose tags do not match these values
|
||||
:param group_roles_by_name: Group roles by name instead of considering them as separate nodes with different IDs
|
||||
"""
|
||||
|
||||
super().__init__(tags=tags, skip_tags=skip_tags)
|
||||
|
||||
self.group_roles_by_name = group_roles_by_name
|
||||
self.include_role_tasks = include_role_tasks
|
||||
self.playbook_filename = playbook_filename
|
||||
|
||||
|
@ -226,9 +228,15 @@ class PlaybookParser(BaseParser):
|
|||
# Go to the next role
|
||||
continue
|
||||
|
||||
if self.group_roles_by_name:
|
||||
# If we are grouping roles, we use the hash of role name as the node id
|
||||
role_node_id = "role_" + hash_value(role.get_name())
|
||||
else:
|
||||
# Otherwise, a random id is used
|
||||
role_node_id = generate_id("role_")
|
||||
role_node = RoleNode(
|
||||
clean_name(role.get_name()),
|
||||
node_id="role_" + hash_value(role.get_name()),
|
||||
node_id=role_node_id,
|
||||
raw_object=role,
|
||||
parent=play_node,
|
||||
)
|
||||
|
@ -341,9 +349,15 @@ class PlaybookParser(BaseParser):
|
|||
|
||||
# Here we are using the role name instead of the task name to keep the same behavior as a
|
||||
# traditional role
|
||||
if self.group_roles_by_name:
|
||||
# If we are grouping roles, we use the hash of role name as the node id
|
||||
role_node_id = "role_" + hash_value(task_or_block._role_name)
|
||||
else:
|
||||
# Otherwise, a random id is used
|
||||
role_node_id = generate_id("role_")
|
||||
role_node = RoleNode(
|
||||
task_or_block._role_name,
|
||||
node_id="role_" + hash_value(task_or_block._role_name),
|
||||
node_id=role_node_id,
|
||||
when=convert_when_to_str(task_or_block.when),
|
||||
raw_object=task_or_block,
|
||||
parent=parent_nodes[-1],
|
||||
|
|
|
@ -167,10 +167,6 @@ class GraphVizPostProcessor:
|
|||
"""
|
||||
# Get Bézier curve
|
||||
path_segments = parse_path(path_element.get("d"))
|
||||
# FIXME: apply the translation to the segments ?
|
||||
# TRANSLATE_PATTERN = re.compile(".*translate\((?P<x>[+-]?[0-9]*[.]?[0-9]+) (?P<y>[+-]?[0-9]*[.]?[0-9]+)\).*")
|
||||
# transform_attribute = self.root.xpath("//*[@id='graph0']", namespaces={"ns": SVG_NAMESPACE})[0].get("transform")
|
||||
|
||||
# The segments usually contain 3 elements: One MoveTo and one or two CubicBezier objects.
|
||||
# This is relatively slow to compute. Decreasing the "error" will drastically slow down the post-processing
|
||||
segment_length = path_segments.length(error=1e-4)
|
||||
|
@ -195,18 +191,12 @@ class GraphVizPostProcessor:
|
|||
)
|
||||
|
||||
for edge in edge_elements:
|
||||
path_elements = edge.findall(".//path", namespaces=self.root.nsmap)
|
||||
display.vvvvv(
|
||||
f"{DISPLAY_PREFIX} {len(path_elements)} path(s) found on the edge '{edge.get('id')}'"
|
||||
)
|
||||
text_element = edge.find(".//text", namespaces=self.root.nsmap)
|
||||
|
||||
# Define an ID for the path so that we can reference it explicitly
|
||||
path_id = f"path_{edge.get('id')}"
|
||||
# Even though we may have more than one path, we only care about a single on.
|
||||
# We have more than one path (edge) pointing to a single task if role containing the task is used more than
|
||||
# once.
|
||||
path_element = path_elements[0]
|
||||
|
||||
path_element = edge.find(".//path", namespaces=self.root.nsmap)
|
||||
path_element.set("id", path_id)
|
||||
|
||||
# Create a curved textPath: the text will follow the path
|
||||
|
@ -219,8 +209,7 @@ class GraphVizPostProcessor:
|
|||
|
||||
text_element.append(text_path)
|
||||
|
||||
# The more paths we have, the more we move the text from the path
|
||||
dy = -0.2 - (len(path_elements) - 1) * 0.3
|
||||
dy = -0.2
|
||||
text_element.set("dy", f"{dy}%")
|
||||
# Remove unnecessary attributes and clear the text
|
||||
text_element.attrib.pop("x", "")
|
||||
|
|
|
@ -63,7 +63,7 @@ def grapher_cli(request) -> PlaybookGrapherCLI:
|
|||
:return:
|
||||
"""
|
||||
# The request param should be the path to the playbook
|
||||
args_params = request.param
|
||||
args_params = request.param.copy()
|
||||
# The last item of the args should be the name of the playbook file in the fixtures.
|
||||
args_params[-1] = os.path.join(FIXTURES_DIR, args_params[-1])
|
||||
cli = PlaybookGrapherCLI([__prog__] + args_params)
|
||||
|
|
13
tests/fixtures/group-roles-by-name.yml
vendored
Normal file
13
tests/fixtures/group-roles-by-name.yml
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
- hosts: all
|
||||
roles:
|
||||
- role: fake_role
|
||||
when: ansible_distribution == "Debian"
|
||||
- role: display_some_facts
|
||||
tasks:
|
||||
- block:
|
||||
- include_role:
|
||||
name: fake_role
|
||||
post_tasks:
|
||||
- include_role:
|
||||
name: nested_include_role
|
|
@ -215,17 +215,53 @@ def test_block_parsing(grapher_cli: PlaybookGrapherCLI):
|
|||
|
||||
|
||||
@pytest.mark.parametrize("grapher_cli", [["multi-plays.yml"]], indirect=True)
|
||||
def test_roles_usage(grapher_cli: PlaybookGrapherCLI):
|
||||
@pytest.mark.parametrize(
|
||||
[
|
||||
"group_roles_by_name",
|
||||
"roles_number",
|
||||
"fake_role_usage",
|
||||
"display_some_facts_usage",
|
||||
"nested_include_role",
|
||||
],
|
||||
[(False, 8, 1, 1, 1), (True, 3, 3, 4, 1)],
|
||||
ids=["no_group", "group"],
|
||||
)
|
||||
def test_roles_usage(
|
||||
grapher_cli: PlaybookGrapherCLI,
|
||||
roles_number,
|
||||
group_roles_by_name: bool,
|
||||
fake_role_usage,
|
||||
display_some_facts_usage,
|
||||
nested_include_role,
|
||||
):
|
||||
"""
|
||||
|
||||
:param grapher_cli:
|
||||
:param roles_number: The number of uniq roles in the graph
|
||||
:param group_roles_by_name: flag to enable grouping roles or not
|
||||
:param fake_role_usage: number of usages for the role fake_role
|
||||
:param display_some_facts_usage: number of usages for the role display_some_facts
|
||||
:param nested_include_role: number of usages for the role nested_include_role
|
||||
:return:
|
||||
"""
|
||||
parser = PlaybookParser(
|
||||
grapher_cli.options.playbook_filenames[0], include_role_tasks=True
|
||||
grapher_cli.options.playbook_filenames[0],
|
||||
include_role_tasks=True,
|
||||
group_roles_by_name=group_roles_by_name,
|
||||
)
|
||||
playbook_node = parser.parse()
|
||||
roles_usage = playbook_node.roles_usage()
|
||||
|
||||
expectation = {
|
||||
"fake_role": fake_role_usage,
|
||||
"display_some_facts": display_some_facts_usage,
|
||||
"nested_include_role": nested_include_role,
|
||||
}
|
||||
|
||||
assert roles_number == len(
|
||||
roles_usage
|
||||
), "The number of unique roles should be equal to the number of usages"
|
||||
|
||||
for role, plays in roles_usage.items():
|
||||
assert all(
|
||||
map(lambda node: node.id.startswith("play_"), plays)
|
||||
|
@ -233,15 +269,6 @@ def test_roles_usage(grapher_cli: PlaybookGrapherCLI):
|
|||
|
||||
nb_plays_for_the_role = len(plays)
|
||||
|
||||
if role.name == "fake_role":
|
||||
assert (
|
||||
nb_plays_for_the_role == 3
|
||||
), "The role fake_role is used 3 times in the plays"
|
||||
elif role.name == "display_some_facts":
|
||||
assert (
|
||||
nb_plays_for_the_role == 4
|
||||
), "The role display_some_facts is used 4 times in the plays"
|
||||
elif role.name == "nested_included_role":
|
||||
assert (
|
||||
nb_plays_for_the_role == 1
|
||||
), "The role nested_included_role is used in 1 play"
|
||||
assert (
|
||||
expectation.get(role.name) == nb_plays_for_the_role
|
||||
), f"The role {role.name} is used {fake_role_usage} times in the play instead of {nb_plays_for_the_role}"
|
||||
|
|
|
@ -110,7 +110,7 @@ def _common_tests(
|
|||
|
||||
assert (
|
||||
len(roles) == roles_number
|
||||
), f"The playbook '{svg_path}' should contains {roles_number} role(s) but we found {len(roles)} role(s)"
|
||||
), f"The graph '{svg_path}' should contains {roles_number} role(s) but we found {len(roles)} role(s)"
|
||||
|
||||
assert (
|
||||
len(tasks) == tasks_number
|
||||
|
@ -224,7 +224,7 @@ def test_with_roles(request, include_role_tasks_option, expected_tasks_number):
|
|||
|
||||
@pytest.mark.parametrize(
|
||||
["include_role_tasks_option", "expected_tasks_number"],
|
||||
[("--", 2), ("--include-role-tasks", 8)],
|
||||
[("--", 2), ("--include-role-tasks", 14)],
|
||||
ids=["no_include_role_tasks_option", "include_role_tasks_option"],
|
||||
)
|
||||
def test_include_role(request, include_role_tasks_option, expected_tasks_number):
|
||||
|
@ -243,7 +243,7 @@ def test_include_role(request, include_role_tasks_option, expected_tasks_number)
|
|||
plays_number=1,
|
||||
blocks_number=1,
|
||||
tasks_number=expected_tasks_number,
|
||||
roles_number=3,
|
||||
roles_number=6,
|
||||
)
|
||||
|
||||
|
||||
|
@ -419,9 +419,9 @@ def test_multi_plays(request):
|
|||
svg_path=svg_path,
|
||||
playbook_paths=playbook_paths,
|
||||
plays_number=3,
|
||||
tasks_number=10,
|
||||
tasks_number=25,
|
||||
post_tasks_number=2,
|
||||
roles_number=3,
|
||||
roles_number=8,
|
||||
pre_tasks_number=2,
|
||||
)
|
||||
|
||||
|
@ -442,8 +442,8 @@ def test_multiple_playbooks(request):
|
|||
playbooks_number=3,
|
||||
plays_number=5,
|
||||
pre_tasks_number=4,
|
||||
roles_number=3,
|
||||
tasks_number=14,
|
||||
roles_number=10,
|
||||
tasks_number=35,
|
||||
post_tasks_number=4,
|
||||
)
|
||||
|
||||
|
@ -496,3 +496,31 @@ def test_community_download_roles_and_collection(request):
|
|||
output_filename=request.node.name,
|
||||
additional_args=["--include-role-tasks"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
["flag", "roles_number", "tasks_number", "post_tasks_number"],
|
||||
[("--", 6, 9, 8), ("--group-roles-by-name", 3, 6, 2)],
|
||||
ids=["no_group", "group"],
|
||||
)
|
||||
def test_group_roles_by_name(
|
||||
request, flag, roles_number, tasks_number, post_tasks_number
|
||||
):
|
||||
"""
|
||||
Test group roles by name
|
||||
:return:
|
||||
"""
|
||||
svg_path, playbook_paths = run_grapher(
|
||||
["group-roles-by-name.yml"],
|
||||
output_filename=request.node.name,
|
||||
additional_args=["--include-role-tasks", flag],
|
||||
)
|
||||
_common_tests(
|
||||
svg_path=svg_path,
|
||||
playbook_paths=playbook_paths,
|
||||
plays_number=1,
|
||||
roles_number=roles_number,
|
||||
tasks_number=tasks_number,
|
||||
post_tasks_number=post_tasks_number,
|
||||
blocks_number=1,
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue