feat: Add ability do distinguish the explicit block on the graph (#86)

This commit is contained in:
Mohamed El Mouctar Haidara 2021-09-26 18:18:09 +02:00 committed by GitHub
parent 9a0fdec592
commit 77d3f2bd6a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 192 additions and 83 deletions

View file

@ -10,11 +10,14 @@
- feat: Curved edge label based on the path [\#84](https://github.com/haidaraM/ansible-playbook-grapher/pull/84)
- feat: Add option to automatically view the generated
file [\#88](https://github.com/haidaraM/ansible-playbook-grapher/pull/88)
- feat: Add support for block [\#86](https://github.com/haidaraM/ansible-playbook-grapher/pull/86)
- fix:
- front: Refactor the JS part and fix issue when selecting/unselecting nodes
- front: Do not unhighlight the current selected node when hovering on parent node
- cli(typo): rename `--ouput-file-name` to `--output-file-name`
- fix: Use the correct tooltip for edges
- front: Refactor the JS part and fix issue when selecting/unselecting nodes
- front: Do not unhighlight the current selected node when hovering on parent node
- cli(typo): rename `--ouput-file-name` to `--output-file-name`
- Use the correct tooltip for edges
- style: Do not use bold style by default and apply color on nodes border
- Merge when condition with `and`
- test:
- Add Ansible 2.10.7 in the test matrix
- Make test verbose by default with `-vv` in the args

View file

@ -100,6 +100,7 @@ function clickOnElement(event) {
$("#svg").ready(function () {
let plays = $("g[id^=play_]");
let roles = $("g[id^=role_]");
let blocks = $("g[id^=block_]");
// Set hover and click events on the plays
plays.hover(hoverMouseEnter, hoverMouseLeave);
@ -109,4 +110,8 @@ $("#svg").ready(function () {
roles.hover(hoverMouseEnter, hoverMouseLeave);
roles.click(clickOnElement);
// Set hover and click events on the blocks
blocks.hover(hoverMouseEnter, hoverMouseLeave);
blocks.click(clickOnElement);
});

View file

@ -1,11 +1,10 @@
from abc import ABC
from collections import defaultdict
from typing import Dict, List
from ansibleplaybookgrapher.utils import generate_id
class Node(ABC):
class Node:
"""
A node in the graph. Everything of the final graph is a node: playbook, plays, edges, tasks and roles.
"""
@ -29,13 +28,14 @@ class CompositeNode(Node):
A node that composed of multiple of nodes.
"""
def __init__(self, node_name: str, node_id: str):
def __init__(self, node_name: str, node_id: str, supported_compositions: List[str] = None):
"""
:param node_name:
:param node_id:
"""
super().__init__(node_name, node_id)
self._supported_compositions = supported_compositions or []
# The dict will contain the different types of composition.
self._compositions = defaultdict(list) # type: Dict[str, List]
@ -54,6 +54,9 @@ class CompositeNode(Node):
:param node: The node to add in the given composition
:return:
"""
if target_composition not in self._supported_compositions:
raise Exception(
f"The target composition '{target_composition}' is unknown. Supported are: {self._supported_compositions}")
self._compositions[target_composition].append(node)
def links_structure(self) -> Dict[Node, List[Node]]:
@ -84,7 +87,7 @@ class PlaybookNode(CompositeNode):
"""
def __init__(self, node_name: str, plays: List['PlayNode'] = None, node_id: str = None):
super().__init__(node_name, node_id or generate_id("playbook_"))
super().__init__(node_name, node_id or generate_id("playbook_"), ["plays"])
self._compositions['plays'] = plays or []
@property
@ -122,7 +125,7 @@ class PlayNode(CompositeNode):
:param node_id:
:param hosts: List of hosts attached to the play
"""
super().__init__(node_name, node_id or generate_id("play_"))
super().__init__(node_name, node_id or generate_id("play_"), ["pre_tasks", "roles", "tasks", "post_tasks"])
self.hosts = hosts or []
@property
@ -142,6 +145,23 @@ class PlayNode(CompositeNode):
return self._compositions["tasks"]
class BlockNode(CompositeNode):
"""
A block node: https://docs.ansible.com/ansible/latest/user_guide/playbooks_blocks.html
"""
def __init__(self, node_name: str, node_id: str = None):
super().__init__(node_name, node_id or generate_id("block_"), ["tasks"])
@property
def tasks(self) -> List['EdgeNode']:
"""
The tasks attached to this block
:return:
"""
return self._compositions['tasks']
class EdgeNode(CompositeNode):
"""
An edge between two nodes. It's a special case of composite node with only one composition with one element
@ -155,9 +175,9 @@ class EdgeNode(CompositeNode):
:param destination: The edge destination node
:param node_id: The edge id
"""
super().__init__(node_name, node_id or generate_id("edge_"))
super().__init__(node_name, node_id or generate_id("edge_"), ["destination"])
self.source = source
self.add_node("nodes", destination)
self.add_node("destination", destination)
def add_node(self, target_composition: str, node: Node):
"""
@ -177,7 +197,7 @@ class EdgeNode(CompositeNode):
Return the destination of the edge
:return:
"""
return self._compositions["nodes"][0]
return self._compositions["destination"][0]
class TaskNode(Node):
@ -195,7 +215,7 @@ class RoleNode(CompositeNode):
"""
def __init__(self, node_name: str, node_id: str = None):
super().__init__(node_name, node_id or generate_id("role_"))
super().__init__(node_name, node_id or generate_id("role_"), ["tasks"])
@property
def tasks(self):

View file

@ -13,8 +13,9 @@ from ansible.playbook.task_include import TaskInclude
from ansible.template import Templar
from ansible.utils.display import Display
from ansibleplaybookgrapher.graph import EdgeNode, TaskNode, PlaybookNode, RoleNode, PlayNode, CompositeNode
from ansibleplaybookgrapher.utils import clean_name, handle_include_path, has_role_parent, generate_id
from ansibleplaybookgrapher.graph import EdgeNode, TaskNode, PlaybookNode, RoleNode, PlayNode, CompositeNode, BlockNode
from ansibleplaybookgrapher.utils import clean_name, handle_include_path, has_role_parent, generate_id, \
convert_when_to_str
class BaseParser(ABC):
@ -73,12 +74,8 @@ class BaseParser(ABC):
self.display.vv(f"Adding {node_type} '{task.get_name()}' to the graph")
edge_label = ""
if len(task.when) > 0:
when = "".join(map(str, task.when))
edge_label += " [when: " + when + "]"
task_name = clean_name(f"[{node_type}] " + self.template(task.get_name(), task_vars))
edge_label = convert_when_to_str(task.when)
edge_node = EdgeNode(parent_node, TaskNode(task_name, generate_id(f"{node_type}_")), edge_label)
parent_node.add_node(target_composition=f"{node_type}s", node=edge_node)
@ -214,6 +211,13 @@ class PlaybookParser(BaseParser):
:return:
"""
if not block._implicit and block._role is None:
# Here we have an explicit block. Ansible internally converts all normal tasks to Block
block_node = BlockNode(str(block.name))
parent_nodes[-1].add_node(f"{node_type}s",
EdgeNode(parent_nodes[-1], block_node, convert_when_to_str(block.when)))
parent_nodes.append(block_node)
# loop through the tasks
for task_or_block in block.block:
if isinstance(task_or_block, Block):
@ -233,7 +237,7 @@ class PlaybookParser(BaseParser):
role_node = RoleNode(task_or_block.args['name'])
# TODO: add support for conditional (when) for include_role in the edge label
parent_nodes[-1].add_node("roles", EdgeNode(parent_nodes[-1], role_node))
parent_nodes[-1].add_node(f"{node_type}s", EdgeNode(parent_nodes[-1], role_node))
if self.include_role_tasks:
# If we have an include_role and we want to include role tasks, the parent node now becomes
@ -274,9 +278,12 @@ class PlaybookParser(BaseParser):
self._include_tasks_in_blocks(current_play=current_play, parent_nodes=parent_nodes, block=b,
play_vars=task_vars, node_type=node_type)
else:
if len(parent_nodes) > 1 and not has_role_parent(task_or_block):
# We add a new parent node only if we found an include_role. If an include_role is not found, and we
# have a task that is not from an include_role, we remove the last RoleNode we have added.
if len(parent_nodes) > 1 and not has_role_parent(task_or_block) and task_or_block._parent._implicit:
# We add a new parent node if:
# - We found an include_role
# - We found an explicit Block
# If an include_role is not found and we have a task that is not from an include_role and not from
# an explicit block => we remove the last CompositeNode we have added.
parent_nodes.pop()
# check if this task comes from a role, and we don't want to include tasks of the role

View file

@ -4,7 +4,7 @@ from typing import Dict
from ansible.utils.display import Display
from graphviz import Digraph
from ansibleplaybookgrapher.graph import PlaybookNode, EdgeNode, Node, PlayNode, RoleNode
from ansibleplaybookgrapher.graph import PlaybookNode, EdgeNode, PlayNode, RoleNode, BlockNode
from ansibleplaybookgrapher.utils import clean_name, get_play_colors
@ -40,27 +40,89 @@ class GraphvizRenderer:
"""
self.display = display
self.playbook_node = playbook_node
self.graphviz = GraphvizCustomDigraph(format=graph_format,
graph_attr=graph_attr or GraphvizRenderer.DEFAULT_GRAPH_ATTR,
edge_attr=edge_attr or GraphvizRenderer.DEFAULT_EDGE_ATTR)
self.digraph = GraphvizCustomDigraph(format=graph_format,
graph_attr=graph_attr or GraphvizRenderer.DEFAULT_GRAPH_ATTR,
edge_attr=edge_attr or GraphvizRenderer.DEFAULT_EDGE_ATTR)
def _add_task(self, graph: GraphvizCustomDigraph, parent_node: Node, edge: EdgeNode, color: str, task_counter: int,
shape: str = "octagon"):
def render_node(self, graph: GraphvizCustomDigraph, edge: EdgeNode, color: str, node_counter: int,
shape: str = "octagon"):
"""
Add a task in the given graph
:param graph:
:param parent_node
:param edge:
:param color
:param shape
Render a generic node in the graph
:param graph: The graph to render the node to
:param edge: The edge from a node to the Node
:param color: The color to apply
:param node_counter: The counter for this node
:param shape: the default shape of the node
:return:
"""
destination_node = edge.destination
graph.node(destination_node.id, label=destination_node.name, shape=shape, id=destination_node.id,
tooltip=destination_node.name)
edge_label = f"{task_counter} {edge.name}"
graph.edge(parent_node.id, destination_node.id, label=edge_label, color=color, fontcolor=color, style="bold",
id=edge.id, tooltip=edge_label, labeltooltip=edge_label)
source_node = edge.source
if isinstance(destination_node, BlockNode):
self.render_block(graph, node_counter, edge, color)
elif isinstance(destination_node, RoleNode):
self.render_role(graph, node_counter, edge, color)
else:
edge_label = f"{node_counter} {edge.name}"
graph.node(destination_node.id, label=destination_node.name, shape=shape, id=destination_node.id,
tooltip=destination_node.name, color=color)
graph.edge(source_node.id, destination_node.id, label=edge_label, color=color, fontcolor=color, id=edge.id,
tooltip=edge_label, labeltooltip=edge_label)
def render_block(self, graph: Digraph, edge_counter: int, edge: EdgeNode, color: str, **kwargs):
"""
Render a block in the graph.
A BlockNode is a special node: a cluster is created instead of a normal node.
:param graph: The graph to render the block into
:param edge_counter: The counter for this edge in the graph
:param edge: The edge from a node to the BlockNode
:param color: The color to apply
:param kwargs:
:return:
"""
# noinspection PyTypeChecker
destination_node = edge.destination # type: BlockNode
edge_label = f"{edge_counter}"
if graph.name.startswith("cluster"):
# We are rendering a block inside another block.
# In that case, we add the edge name as label also to materialize the when condition
edge_label = f"{edge_counter} {edge.name}"
# BlockNode is a special node: a cluster is created instead of a normal node
with graph.subgraph(name=f"cluster_{destination_node.id}") as block_subgraph:
block_subgraph.node(destination_node.id, label=f"[block] {destination_node.name}", shape="box",
id=destination_node.id, tooltip=destination_node.name, color=color,
labeltooltip=destination_node.name)
graph.edge(edge.source.id, destination_node.id, label=edge_label, color=color, fontcolor=color,
tooltip=edge_label, id=edge.id, labeltooltip=edge_label)
for b_counter, task_edge_node in enumerate(destination_node.tasks, 1):
self.render_node(block_subgraph, task_edge_node, color, b_counter)
def render_role(self, graph: Digraph, edge_counter: int, edge: EdgeNode, color: str, **kwargs):
"""
Render a role in the graph
:param graph: The graph to render the role into
:param edge_counter: The counter for this edge in the graph
:param edge: The edge from a node to the RoleNode
:param color: The color to apply
:param kwargs:
:return:
"""
# noinspection PyTypeChecker
role = edge.destination # type: RoleNode
role_edge_label = f"{edge_counter} {edge.name}"
with self.digraph.subgraph(name=role.name, node_attr={}) as role_subgraph:
role_subgraph.node(role.id, id=role.id, label=f"[role] {role.name}", tooltip=role.name, color=color)
# from parent to role
graph.edge(edge.source.id, role.id, label=role_edge_label, color=color, fontcolor=color, id=edge.id,
tooltip=role_edge_label, labeltooltip=role_edge_label)
# role tasks
for role_task_counter, role_task_edge in enumerate(role.tasks, 1):
self.render_node(role_subgraph, role_task_edge, color, node_counter=role_task_counter)
def _convert_to_graphviz(self):
"""
@ -68,56 +130,41 @@ class GraphvizRenderer:
:return:
"""
# root node
self.graphviz.node(self.playbook_node.name, style="dotted", id="root_node")
self.digraph.node(self.playbook_node.name, style="dotted", id="root_node")
for play_counter, play_edge in enumerate(self.playbook_node.plays, 1):
# noinspection PyTypeChecker
play = play_edge.destination # type: PlayNode
with self.graphviz.subgraph(name=play.name) as play_subgraph:
with self.digraph.subgraph(name=play.name) as play_subgraph:
color, play_font_color = get_play_colors(play)
# play node
play_tooltip = ",".join(play.hosts) if len(play.hosts) > 0 else play.name
self.graphviz.node(play.id, id=play.id, label=play.name, style="filled", shape="box", color=color,
fontcolor=play_font_color, tooltip=play_tooltip)
self.digraph.node(play.id, id=play.id, label=play.name, style="filled", shape="box", color=color,
fontcolor=play_font_color, tooltip=play_tooltip)
# edge from root node to play
playbook_to_play_label = f"{play_counter} {play_edge.name}"
self.graphviz.edge(self.playbook_node.name, play.id, id=play_edge.id, style="bold",
label=playbook_to_play_label, color=color, fontcolor=color,
tooltip=playbook_to_play_label, labeltooltip=playbook_to_play_label)
self.digraph.edge(self.playbook_node.name, play.id, id=play_edge.id, label=playbook_to_play_label,
color=color, fontcolor=color, tooltip=playbook_to_play_label,
labeltooltip=playbook_to_play_label)
# pre_tasks
for pre_task_counter, pre_task_edge in enumerate(play.pre_tasks, 1):
self._add_task(graph=play_subgraph, parent_node=play, edge=pre_task_edge, color=color,
task_counter=pre_task_counter)
self.render_node(play_subgraph, pre_task_edge, color, node_counter=pre_task_counter)
# roles
for role_counter, role_edge in enumerate(play.roles, 1):
# noinspection PyTypeChecker
role = role_edge.destination # type: RoleNode
role_edge_label = f"{role_counter + len(play.pre_tasks)} {role_edge.name}"
with self.graphviz.subgraph(name=role.name, node_attr={}) as role_subgraph:
# from play to role
role_subgraph.node(role.id, id=role.id, label=f"[role] {role.name}", tooltip=role.name)
play_subgraph.edge(play.id, role.id, label=role_edge_label, color=color, fontcolor=color,
style="bold", id=role_edge.id, tooltip=role_edge_label,
labeltooltip=role_edge_label)
# role tasks
for role_task_counter, role_task_edge in enumerate(role.tasks, 1):
self._add_task(role_subgraph, role, role_task_edge, color, task_counter=role_task_counter)
self.render_role(self.digraph, role_counter + len(play.pre_tasks), role_edge, color)
# tasks
for task_counter, task_edge in enumerate(play.tasks, 1):
self._add_task(play_subgraph, play, task_edge, color,
task_counter=len(play.pre_tasks) + len(play.roles) + task_counter)
self.render_node(play_subgraph, task_edge, color,
node_counter=len(play.pre_tasks) + len(play.roles) + task_counter)
# post_tasks
for post_task_counter, post_task_edge in enumerate(play.post_tasks, 1):
self._add_task(play_subgraph, play, post_task_edge, color,
task_counter=len(play.pre_tasks) + len(play.roles) + len(
play.tasks) + post_task_counter)
self.render_node(play_subgraph, post_task_edge, color,
node_counter=len(play.pre_tasks) + len(play.roles) + len(
play.tasks) + post_task_counter)
def render(self, output_filename: str, save_dot_file=False, view=False) -> str:
"""
@ -129,8 +176,8 @@ class GraphvizRenderer:
:return: The rendered file path (output_filename.svg)
"""
self._convert_to_graphviz()
rendered_file_path = self.graphviz.render(cleanup=not save_dot_file, format="svg", filename=output_filename,
view=view)
rendered_file_path = self.digraph.render(cleanup=not save_dot_file, format="svg", filename=output_filename,
view=view)
if save_dot_file:
# add .dot extension. The render doesn't add an extension

View file

@ -1,8 +1,9 @@
import os
import uuid
from typing import Tuple
from typing import Tuple, List
from ansible.parsing.dataloader import DataLoader
from ansible.parsing.yaml.objects import AnsibleUnicode
from ansible.playbook.role_include import IncludeRole
from ansible.playbook.task import Task
from ansible.playbook.task_include import TaskInclude
@ -10,6 +11,16 @@ from ansible.template import Templar
from colour import Color
def convert_when_to_str(when: List[AnsibleUnicode]) -> str:
"""
Convert ansible conditional when to str
:param when:
:return:
"""
return f"[when: {' and '.join(when)}]" if len(when) > 0 else ""
def generate_id(prefix: str = "") -> str:
"""
Generate an uuid to be used as id

View file

@ -1,7 +1,11 @@
---
- hosts: all
tasks:
- name: Install tree
yum:
name: tree
- name: Install Apache
when: (ansible_facts['distribution'] == "CentOS" and ansible_facts['distribution_major_version'] == "6")
block:
- name: Install some packages
yum:
@ -13,7 +17,15 @@
- template:
src: templates/src.j2
dest: /etc/foo.conf
- block:
- get_url: url={{ remote_database_dump }} dest={{ local_database_dump }}
when: "True"
- command: pg_restore -d {{ dbname }} {{ local_database_dump }}
- service:
name: bar
state: started
enabled: True
enabled: True
post_tasks:
- name: Debug
debug:
msg: "Debug 1"

View file

@ -45,7 +45,7 @@ def run_grapher(playbook_file: str, output_filename: str = None, additional_args
def _common_tests(svg_path: str, playbook_path: str, plays_number: int = 0, tasks_number: int = 0,
post_tasks_number: int = 0, roles_number: int = 0,
pre_tasks_number: int = 0) -> Dict[str, List[Element]]:
pre_tasks_number: int = 0, blocks_number: int = 0) -> Dict[str, List[Element]]:
"""
Perform some common tests on the generated svg file:
- Existence of svg file
@ -70,19 +70,22 @@ def _common_tests(svg_path: str, playbook_path: str, plays_number: int = 0, task
tasks = pq("g[id^='task_']")
post_tasks = pq("g[id^='post_task_']")
pre_tasks = pq("g[id^='pre_task_']")
blocks = pq("g[id^='block_']")
roles = pq("g[id^='role_']")
assert plays_number == len(plays), "The playbook '{}' should contains {} play(s) but we found {} play(s)".format(
assert plays_number == len(plays), "The graph '{}' should contains {} play(s) but we found {} play(s)".format(
playbook_path, plays_number, len(plays))
assert tasks_number == len(tasks), "The playbook '{}' should contains {} tasks(s) we found {} tasks".format(
playbook_path, tasks_number, len(tasks))
assert post_tasks_number == len(post_tasks), "The '{}' playbook should contains {} post tasks(s) we found {} " \
"post tasks".format(playbook_path, post_tasks_number, len(post_tasks))
assert pre_tasks_number == len(pre_tasks), "The playbook '{}' should contains {} pre tasks(s) but we found {} " \
assert pre_tasks_number == len(pre_tasks), "The graph '{}' should contains {} pre tasks(s) but we found {} " \
"pre tasks".format(playbook_path, pre_tasks_number, len(pre_tasks))
assert roles_number == len(roles), "The playbook '{}' should contains {} role(s) but we found {} role(s)".format(
playbook_path, roles_number, len(roles))
assert tasks_number == len(tasks), "The graph '{}' should contains {} tasks(s) but we found {} tasks".format(
playbook_path, tasks_number, len(tasks))
assert post_tasks_number == len(post_tasks), "The graph '{}' should contains {} post tasks(s) but we found {} " \
"post tasks".format(playbook_path, post_tasks_number, len(post_tasks))
assert blocks_number == len(blocks), "The graph '{}' should contains {} blocks(s) but we found {} " \
"pre tasks".format(playbook_path, blocks_number, len(blocks))
return {'tasks': tasks, 'plays': plays, 'post_tasks': post_tasks, 'pre_tasks': pre_tasks, "roles": roles}
@ -160,7 +163,8 @@ def test_with_block(request):
"""
svg_path, playbook_path = run_grapher("with_block.yml", output_filename=request.node.name)
_common_tests(svg_path=svg_path, playbook_path=playbook_path, plays_number=1, tasks_number=3)
_common_tests(svg_path=svg_path, playbook_path=playbook_path, plays_number=1, tasks_number=6, post_tasks_number=1,
blocks_number=2)
def test_nested_include_tasks(request):