feat: Add a flag to group roles by name - Revert the old behavior (#122)

This commit is contained in:
Mohamed El Mouctar Haidara 2022-08-20 14:35:27 +02:00 committed by GitHub
parent e7f23daee7
commit 75f26a9ea2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 168 additions and 62 deletions

View file

@ -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 systems default viewer application for the file type
--view Automatically open the resulting SVG file with your
systems 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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