On 10 Jul 2024, at 19:04, Adrian Moreno wrote:
> Graph view leverages the TreeFlow hierarchy and uses graphviz library to
> build a visual graph of the datapath tree.
>
> Conntrack zones are shown in random colors to help visualize connection
> tracking interdependencies.
>
> An html flag builds an HTML page with both the html flows and the graph
> (in svg) that enables navegation.
navegation -> navigation
>
> Examples:
> $ ovs-appctl dpctl/dump-flows -m | ovs-flowviz datapath graph | dot
> -Tpng -o graph.png
> $ ovs-appctl dpctl/dump-flows -m | ovs-flowviz datapath graph --html >
> flows.html
>
> Signed-off-by: Adrian Moreno <[email protected]>
Thanks for sending the v5, the changes look good to me with some small spelling
errors.
You can add my ack on the next rebase version if fixed.
//Eelco
Acked-by: Eelco Chaudron <[email protected]>
> ---
> python/automake.mk | 1 +
> python/ovs/flowviz/odp/cli.py | 23 ++
> python/ovs/flowviz/odp/graph.py | 481 ++++++++++++++++++++++++++++++++
> python/setup.py | 2 +-
> 4 files changed, 506 insertions(+), 1 deletion(-)
> create mode 100644 python/ovs/flowviz/odp/graph.py
>
> diff --git a/python/automake.mk b/python/automake.mk
> index d534b52d9..b4521292f 100644
> --- a/python/automake.mk
> +++ b/python/automake.mk
> @@ -71,6 +71,7 @@ ovs_flowviz = \
> python/ovs/flowviz/main.py \
> python/ovs/flowviz/odp/__init__.py \
> python/ovs/flowviz/odp/cli.py \
> + python/ovs/flowviz/odp/graph.py \
> python/ovs/flowviz/odp/html.py \
> python/ovs/flowviz/odp/tree.py \
> python/ovs/flowviz/ofp/__init__.py \
> diff --git a/python/ovs/flowviz/odp/cli.py b/python/ovs/flowviz/odp/cli.py
> index 73fadef95..294ab7636 100644
> --- a/python/ovs/flowviz/odp/cli.py
> +++ b/python/ovs/flowviz/odp/cli.py
> @@ -15,6 +15,8 @@
> import click
>
> from ovs.flowviz.main import maincli
> +
> +from ovs.flowviz.odp.graph import GraphProcessor
> from ovs.flowviz.odp.html import HTMLTreeProcessor
> from ovs.flowviz.odp.tree import ConsoleTreeProcessor
> from ovs.flowviz.process import (
> @@ -84,3 +86,24 @@ def html(opts):
> processor = HTMLTreeProcessor(opts)
> processor.process()
> processor.print()
> +
> +
> [email protected]()
> [email protected](
> + "-h",
> + "--html",
> + is_flag=True,
> + default=False,
> + show_default=True,
> + help="Output an html file containing the graph",
> +)
> [email protected]_obj
> +def graph(opts, html):
> + """Print the flows in an graphviz (.dot) format showing the relationship
an graphviz -> a graphviz
> + of recirc_ids."""
> + if len(opts.get("filename")) > 1:
> + raise click.BadParameter("Graph format only supports one input file")
> +
> + processor = GraphProcessor(opts)
> + processor.process()
> + processor.print(html)
> diff --git a/python/ovs/flowviz/odp/graph.py b/python/ovs/flowviz/odp/graph.py
> new file mode 100644
> index 000000000..c17580259
> --- /dev/null
> +++ b/python/ovs/flowviz/odp/graph.py
> @@ -0,0 +1,481 @@
> +# Copyright (c) 2023 Red Hat, Inc.
> +#
> +# Licensed under the Apache License, Version 2.0 (the "License");
> +# you may not use this file except in compliance with the License.
> +# You may obtain a copy of the License at:
> +#
> +# http://www.apache.org/licenses/LICENSE-2.0
> +#
> +# Unless required by applicable law or agreed to in writing, software
> +# distributed under the License is distributed on an "AS IS" BASIS,
> +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
> +# See the License for the specific language governing permissions and
> +# limitations under the License.
> +
> +""" Defines a Datapath Graph using graphviz. """
> +import colorsys
> +import graphviz
> +import random
> +
> +from ovs.flowviz.odp.html import HTMLTree, HTMLFormatter
> +from ovs.flowviz.odp.tree import FlowTree
> +from ovs.flowviz.process import FileProcessor
> +
> +
> +class GraphProcessor(FileProcessor):
> + def __init__(self, opts):
> + super().__init__(opts, "odp")
> +
> + def start_file(self, name, filename):
> + self.tree = FlowTree()
> +
> + def start_thread(self, name):
> + pass
> +
> + def stop_thread(self, name):
> + pass
> +
> + def process_flow(self, flow, name):
> + self.tree.add(flow, self.opts.get("filter"))
> +
> + def process(self):
> + super().process(False)
> +
> + def print(self, html):
> + self.tree.build()
> +
> + if len(self.tree.all_recirc_nodes) == 0:
> + return
> +
> + dpg = DatapathGraph(self.tree, self.opts)
> + if not html:
> + print(dpg.source())
> + return
> +
> + html_obj = "<html>"
> + html_obj += "<head>"
> + html_obj += HTMLTree.head()
> + html_obj += "</head>"
> +
> + html_obj += "<body>"
> + html_obj += HTMLTree.begin_body(self.opts)
> + html_obj += "<h1> Flow Graph </h1>"
> + html_obj += """<div style="resize:both;
> + overflow-y:scroll;
> + overflow-x:scroll;
> + border: 1px solid;
> + width:2000px;
> + height:1200px;">"""
> + svg = dpg.pipe(format="svg")
> + html_obj += svg.decode("utf-8")
> + html_obj += "</div>"
> + html_tree = HTMLTree("graph", self.tree, self.opts)
> + html_obj += html_tree.format()
> + html_obj += HTMLTree.end_body()
> + html_obj += "</body>"
> + html_obj += "</html>"
> +
> + print(html_obj)
> +
> +
> +class DatapathGraph:
> + """A DatapathGraph is a class that renders a set of datapath flows into
> + graphviz graphs.
> +
> + Args:
> + tree: FlowTree
> + """
> +
> + ct_styles = {}
> + node_styles = {
> + "default": {
> + "style": {},
> + "desc": "Default",
> + },
> + "match": {
> + "style": {"color": "#0000ff"},
> + "desc": "Flow matches on CT",
> + },
> + "action": {
> + "style": {"color": "#ff0000"},
> + "desc": "Flow(s) has CT as action",
> + },
> + }
> +
> + def __init__(self, tree, opts):
> + self._tree = tree
> + self._opts = opts
> +
> + style = HTMLFormatter(self._opts).style
> +
> + self.bgcolor = (
> + style.get("background").color
> + if style.get("background")
> + else "#f0f0f0"
> + )
> + self.fgcolor = (
> + style.get("default").color if style.get("default") else "black"
> + )
> +
> + self._output_nodes = []
> + self._graph = graphviz.Digraph(
> + "DP flows",
> + node_attr={
> + "shape": "rectangle",
> + "fontcolor": self.fgcolor,
> + "color": self.fgcolor,
> + },
> + edge_attr={
> + "color": self.fgcolor,
> + },
> + )
> + self._graph.attr(color=self.fgcolor)
> + self._graph.attr(fontcolor=self.fgcolor)
> + self._graph.attr(bgcolor=self.bgcolor)
> + self._graph.attr(compound="true")
> + self._graph.attr(rankdir="LR")
> + self._graph.attr(ranksep="3")
> +
> + self._populate_graph()
> +
> + def source(self):
> + """Return the graphviz source representation of the graph."""
> + return self._graph.source
> +
> + def pipe(self, *args, **kwargs):
> + """Output the graph based on arguments given to graphviz.pipe."""
> + return self._graph.pipe(*args, **kwargs)
> +
> + @classmethod
> + def recirc_cluster_name(cls, node):
> + """Name of the recirculation cluster."""
> + return "cluster_recirc_{}_{}".format(hex(node.recirc), node.in_port)
> +
> + @classmethod
> + def inport_cluster_name(cls, inport):
> + """Name of the input port cluster."""
> + return "cluster_inport_{}".format(inport)
> +
> + @classmethod
> + def invis_node_name(cls, cluster_name):
> + """Name of the invisible node."""
> + return "invis_{}".format(cluster_name)
> +
> + @classmethod
> + def output_node_name(cls, port):
> + """Name of the ouput node."""
> + return "output_{}".format(port)
> +
> + @classmethod
> + def block_node_name(cls, block):
> + """Name of the flow block node."""
> + return "flow_block_{}".format(block.flows[0].flow.id)
> +
> + def _block_node(self, block):
> + """Returns the dictionary of attributes of a graphviz node that
> + represents the FlowBlock."""
> +
> + url = "#block_{}".format(block.flows[0].flow.id)
> +
> + # Graphviz supports HTML-ish syntax. Use it to create a table and
> + # place each (summarized) match in each row and the (also summarized)
> + # action in the last one.
> + # A port is added to the actions row called "actions" that helps make
> + # edges start from it.
> + label = (
> + """<<table border="0" """
> + + """cellborder="1" """
> + + """cellspacing="0" """
> + + """cellpadding="4">"""
> + )
> +
> + for i, tflow in enumerate(block.flows):
> + style = "default"
> +
> + # Full representation of the flow to be used in the tooltip.
> + full = (
> + tflow.flow.section("info").string
> + + " "
> + + tflow.flow.section("match").string
> + )
> +
> + # Summarized match: comma separated list of match keys with
> + # their ommitted with "..." or "---" depending on whether the
> + # match is the same for the entire block or not.
> + flowstr = ""
> + equal_keys = [m[1].key for m in block.equal_match]
> + for m in tflow.flow.match_kv:
> + fill = "..."
> + if m.key in equal_keys:
> + fill = "---"
> +
> + flowstr += (
> + m.meta.kstring + m.meta.delim + fill + m.meta.end_delim
> + )
> +
> + if m.key == "ct_state" and m.value != "0/0":
> + style = "match"
> +
> + flowstr += ","
> +
> + flowstr.strip(",")
> + color = self.node_styles.get(style)["style"].get(
> + "color", self.fgcolor
> + )
> +
> + label += f"""<tr>
> + <td tooltip="{full}" href="{url}" color="{color}">{flowstr}</td>
> + </tr>"""
> +
> + # Add a row for the action.
> + fullact = "actions: " + block.flows[0].flow.section("actions").string
> + actstr = ",".join([a.key for a in block.flows[0].flow.actions_kv])
> +
> + has_ct_action = bool(
> + next(
> + filter(
> + lambda x: x.key in ["ct", "ct_clear"],
> + block.flows[0].flow.actions_kv,
> + ),
> + None,
> + )
> + )
> +
> + style = "action" if has_ct_action else "default"
> +
> + color = self.node_styles.get(style)["style"].get("color",
> self.fgcolor)
> +
> + label += f"""<tr>
> + <td cellpadding="4"
> + border="2"
> + href="{url}"
> + tooltip="{fullact}"
> + color="{color}"
> + port="actions">"""
> + label += f"<B>actions:</B> {actstr}"
> + label += "</td></tr>"
> +
> + label += "</table>>"
> +
> + return {
> + "name": self.block_node_name(block),
> + "label": label,
> + "fontsize": "10",
> + "nojustify": "true",
> + "URL": url,
> + }
> +
> + def _create_recirc_cluster(self, node):
> + """Create a cluster for the RecircNode."""
> +
> + cluster_name = self.recirc_cluster_name(node)
> +
> + label = "<<B>[recirc 0x{:0x} in_port {}]</B>>".format(
> + node.recirc, node.in_port
> + )
> +
> + cluster = self._graph.subgraph(name=cluster_name, comment=label)
> + with cluster as sg:
> + sg.attr(rankdir="TB")
> + sg.attr(ranksep="0.02")
> + sg.attr(label=label)
> + sg.attr(margin="5")
> + self._add_blocks_to_graph(sg, node.visible_blocks())
> +
> + self.processed_recircs.append((node.recirc, node.in_port))
> +
> + def _add_blocks_to_graph(self, graph, blocks):
> + """Add FlowBlock objects in interable to the graph."""
> +
> + # Create an invisible node and an edge to the first block so that
> + # it ends up at the top of the cluster.
> + invis = self.invis_node_name(graph.name)
> + graph.node(invis)
> + graph.node(
> + invis,
> + color=self.bgcolor,
> + len="0",
> + shape="point",
> + width="0",
> + height="0",
> + )
> + first = True
> +
> + for block in blocks:
> + graph.node(**self._block_node(block))
> + if first:
> + with graph.subgraph() as c:
> + c.attr(rank="same")
> + c.edge(self.block_node_name(block), invis, style="invis")
> + first = False
> +
> + # Determine next hop based on block actions.
> + self._set_next_node_from_block(block)
> +
> + def _set_next_node_from_block(self, block):
> + """Create edges to other nodes based on the block's next RecircNodes
> + and special actions."""
> + created = False
> +
> + # Start edges from the "actions" port of the block node.
> + name = self.block_node_name(block) + ":actions"
> +
> + # Deal with RecircNodes first.
> + for node in block.next_recirc_nodes:
> + # If the target recirculation cluster has not yet been created,
> + # do it now.
> + if (node.recirc, node.in_port) not in self.processed_recircs:
> + self._create_recirc_cluster(node)
> +
> + cname = self.recirc_cluster_name(node)
> + self._graph.edge(
> + name,
> + self.invis_node_name(cname),
> + lhead=cname,
> + _attributes={"weight": "20"},
> + )
> + created = True
> +
> + # Then, deal with special actions.
> + created |= self._set_next_node_from_actions(
> + name, block.flows[0].flow.actions
> + )
> +
> + if not created:
> + self._graph.edge(name, "end")
> +
> + def _set_next_node_from_actions(self, name, actions):
> + """Create edges to other nodes based on the the action list."""
the the action -> the action
> + created = False
> +
> + for action in actions:
> + key, value = next(iter(action.items()))
> + if key == "check_pkt_len":
> + created |= self._set_next_node_from_actions(
> + name, value.get("gt")
> + )
> + created |= self._set_next_node_from_actions(
> + name, value.get("le")
> + )
> + elif key == "sample":
> + created |= self._set_next_node_from_actions(
> + name, value.get("actions")
> + )
> + elif key == "clone":
> + created |= self._set_next_node_from_actions(
> + name, value.get("actions")
> + )
> + else:
> + created |= self._set_next_node_action(name, key, value)
> + return created
> +
> + def _set_next_node_action(self, name, action_name, action_obj):
> + """Based on the action object, set the next node."""
> + if action_name == "output":
> + port = action_obj.get("port")
> + if port not in self._output_nodes:
> + self._output_nodes.append(port)
> + self._graph.edge(
> + name, self.output_node_name(port), _attributes={"weight":
> "1"}
> + )
> + return True
> + elif action_name in ["drop", "userspace", "controller"]:
> + if action_name not in self._output_nodes:
> + self._output_nodes.append(action_name)
> + self._graph.edge(name, action_name, _attributes={"weight": "1"})
> + return True
> + elif action_name == "ct":
> + zone = action_obj.get("zone", 0)
> + node_name = "CT zone {}".format(action_obj.get("zone",
> "default"))
> + if zone not in self.ct_styles:
> + # Pick a random (highly saturated) color.
> + (r, g, b) = colorsys.hsv_to_rgb(random.random(), 1, 1)
> + color = "#%02x%02x%02x" % (
> + int(r * 255),
> + int(g * 255),
> + int(b * 255),
> + )
> + self.ct_styles[zone] = color
> + self._graph.node(node_name, color=color)
> +
> + color = self.ct_styles[zone]
> + self._graph.edge(name, node_name, style="dashed", color=color)
> + name = node_name
> + return True
> + return False
> +
> + def _populate_graph(self):
> + """Populate the the internal graph."""
> + self.processed_recircs = []
> +
> + # RecircNode clusters are created recursively when an edge is found
> + # pointing to them. Therefore, starting with recirc = 0 nodes.
> + for node in self._tree.recirc_nodes.get(0).values():
> + if node.visible:
> + self._create_recirc_cluster(node)
> +
> + # Create an input node that points to each input subgraph
> + # They are all inside an anonymous subgraph so that they can be
> + # alligned.
> + with self._graph.subgraph() as s:
> + s.attr(rank="same")
> + for inport, node in self._tree.recirc_nodes.get(0).items():
> + if not node.visible:
> + continue
> +
> + node_name = "input_{}".format(inport)
> + cluster_name = self.recirc_cluster_name(node)
> + s.node(
> + node_name,
> + shape="Mdiamond",
> + label="input port {}".format(inport),
> + )
> + self._graph.edge(
> + node_name,
> + self.invis_node_name(cluster_name),
> + lhead=cluster_name,
> + _attributes={"weight": "20"},
> + )
> +
> + # Create the output nodes in a subgraph so that they are alligned.
alligned -> aligned
> + with self._graph.subgraph() as s:
> + for port in self._output_nodes:
> + s.attr(rank="same")
> + if port == "drop":
> + s.node(
> + "drop",
> + shape="Msquare",
> + color="red",
> + label="DROP",
> + rank="sink",
> + )
> + elif port == "controller":
> + s.node(
> + "controller",
> + shape="Msquare",
> + color="blue",
> + label="CONTROLLER",
> + rank="sink",
> + )
> + elif port == "userspace":
> + s.node(
> + "userspace",
> + shape="Msquare",
> + color="blue",
> + label="CONTROLLER",
> + rank="sink",
> + )
> + else:
> + s.node(
> + self.output_node_name(port),
> + shape="Msquare",
> + color="green",
> + label="Port {}".format(port),
> + rank="sink",
> + )
> +
> + # Print node style legend.
> + with self._graph.subgraph(name="cluster_legend") as s:
> + s.attr(label="Legend")
> + for style in self.node_styles.values():
> + s.node(name=style.get("desc"),
> _attributes=style.get("style"))
> diff --git a/python/setup.py b/python/setup.py
> index c734f68f3..018a75eb0 100644
> --- a/python/setup.py
> +++ b/python/setup.py
> @@ -114,7 +114,7 @@ setup_args = dict(
> 'dns': ['unbound'],
> 'flow': flow_extras_require,
> 'flowviz':
> - [*flow_extras_require, 'click', 'rich'],
> + [*flow_extras_require, 'click', 'rich', 'graphviz'],
> },
> scripts=["ovs/flowviz/ovs-flowviz"],
> data_files=["ovs/flowviz/ovs-flowviz.conf"],
> --
> 2.45.2
>
> _______________________________________________
> dev mailing list
> [email protected]
> https://mail.openvswitch.org/mailman/listinfo/ovs-dev
_______________________________________________
dev mailing list
[email protected]
https://mail.openvswitch.org/mailman/listinfo/ovs-dev