2
0
mirror of https://github.com/openvswitch/ovs synced 2025-08-31 14:25:26 +00:00

python: ovs: flowviz: Add datapath tree format.

Datapath flows can be arranged into a "tree"-like structure based on
recirculation ids and input ports.

A recirculation group is composed of flows sharing the same "recirc_id"
and "in_port" match. Within that group, flows are arranged in blocks of
flows that have the same action list. Finally, if an action associated
with one of this "blocks" contains a "recirc" action, the recirculation
group is shown underneath.

When filtering, instead of blindly dropping non-matching flows, drop all
the "subtrees" that don't have any matching flow.

Examples:
$ ovs-flowviz -i dpflows.txt --style dark datapath tree | less -R
$ ovs-flowviz -i dpflows.txt --filter "output.port=eth0" datapath tree

This patch adds the logic to build this structure in a format-agnostic
object called FlowTree and adds support for formatting it in the
console.

Console format supports:
- head-maps formatting of statistics
- hash-based pallete of recirculation ids: each recirculation id is
  assigned a unique color to easily follow the sequence of related
  actions.

Acked-by: Eelco Chaudron <echaudro@redhat.com>
Signed-off-by: Adrian Moreno <amorenoz@redhat.com>
Signed-off-by: Ilya Maximets <i.maximets@ovn.org>
This commit is contained in:
Adrian Moreno
2024-09-25 12:52:09 +02:00
committed by Ilya Maximets
parent 196b86eac0
commit 1135fc3217
6 changed files with 625 additions and 18 deletions

View File

@@ -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/tree.py \
python/ovs/flowviz/ofp/__init__.py \
python/ovs/flowviz/ofp/cli.py \
python/ovs/flowviz/ofp/html.py \

View File

@@ -67,6 +67,15 @@ class KeyValue(object):
def __repr__(self):
return "{}('{}')".format(self.__class__.__name__, self)
def __eq__(self, other):
if isinstance(other, self.__class__):
return self.key == other.key and self.value == other.value
else:
return False
def __ne__(self, other):
return not self.__eq__(other)
class KVDecoders(object):
"""KVDecoders class is used by KVParser to select how to decode the value

View File

@@ -13,6 +13,8 @@
# limitations under the License.
import colorsys
import itertools
import zlib
from rich.console import Console
from rich.color import Color
@@ -79,6 +81,14 @@ class ConsoleBuffer(FlowBuffer):
"""
return self._append(kv.meta.vstring, style)
def append_value_omitted(self, kv):
"""Append an omitted value.
Args:
kv (KeyValue): the KeyValue instance to append
"""
dots = "." * len(kv.meta.vstring)
return self._append(dots, None)
def append_extra(self, extra, style):
"""Append extra string.
Args:
@@ -107,20 +117,21 @@ class ConsoleFormatter(FlowFormatter):
def style_from_opts(self, opts):
return self._style_from_opts(opts, "console", Style)
def print_flow(self, flow, highlighted=None):
def print_flow(self, flow, highlighted=None, omitted=None):
"""Prints a flow to the console.
Args:
flow (ovs_dbg.OFPFlow): the flow to print
style (dict): Optional; style dictionary to use
highlighted (list): Optional; list of KeyValues to highlight
omitted (list): Optional; list of KeyValues to omit
"""
buf = ConsoleBuffer(Text())
self.format_flow(buf, flow, highlighted)
self.console.print(buf.text)
self.format_flow(buf, flow, highlighted, omitted)
self.console.print(buf.text, soft_wrap=True)
def format_flow(self, buf, flow, highlighted=None):
def format_flow(self, buf, flow, highlighted=None, omitted=None):
"""Formats the flow into the provided buffer as a rich.Text.
Args:
@@ -128,9 +139,10 @@ class ConsoleFormatter(FlowFormatter):
flow (ovs_dbg.OFPFlow): the flow to format
style (FlowStyle): Optional; style object to use
highlighted (list): Optional; list of KeyValues to highlight
omitted (list): Optional; list of KeyValues to omit
"""
return super(ConsoleFormatter, self).format_flow(
buf, flow, self.style, highlighted
buf, flow, self.style, highlighted, omitted
)
@@ -157,6 +169,25 @@ def heat_pallete(min_value, max_value):
return heat
def hash_pallete(hue, saturation, value):
"""Generates a color pallete with the cartesian product
of the hsv values provided and returns a callable that assigns a color for
each value hash
"""
HSV_tuples = itertools.product(hue, saturation, value)
RGB_tuples = map(lambda x: colorsys.hsv_to_rgb(*x), HSV_tuples)
styles = [
Style(color=Color.from_rgb(r * 255, g * 255, b * 255))
for r, g, b in RGB_tuples
]
def get_style(string):
hash_val = zlib.crc32(bytes(str(string), "utf-8"))
return styles[hash_val % len(styles)]
return get_style
def default_highlight():
"""Generates a default style for highlights."""
return Style(underline=True)

View File

@@ -225,7 +225,8 @@ class FlowFormatter:
return FlowStyle({k: style_constructor(**v) for k, v in style.items()})
def format_flow(self, buf, flow, style_obj=None, highlighted=None):
def format_flow(self, buf, flow, style_obj=None, highlighted=None,
omitted=None):
"""Formats the flow into the provided buffer.
Args:
@@ -233,25 +234,41 @@ class FlowFormatter:
flow (ovs_dbg.OFPFlow): the flow to format
style_obj (FlowStyle): Optional; style to use
highlighted (list): Optional; list of KeyValues to highlight
omitted (list): Optional; dict of keys to omit indexed by section
name.
"""
last_printed_pos = 0
first = True
if style_obj:
if style_obj or omitted:
style_obj = style_obj or FlowStyle()
for section in sorted(flow.sections, key=lambda x: x.pos):
buf.append_extra(
flow.orig[last_printed_pos : section.pos],
style=style_obj.get("default"),
)
section_omitted = (omitted or {}).get(section.name)
if isinstance(section_omitted, str) and \
section_omitted == "all":
last_printed_pos += section.pos + len(section.string)
continue
# Do not print leading extra strings (e.g: spaces and commas)
# if it's the first section that gets printed.
if not first:
buf.append_extra(
flow.orig[last_printed_pos : section.pos],
style=style_obj.get("default"),
)
self.format_kv_list(
buf, section.data, section.string, style_obj, highlighted
buf, section.data, section.string, style_obj, highlighted,
section_omitted
)
last_printed_pos = section.pos + len(section.string)
first = False
else:
# Don't pay the cost of formatting each section one by one.
buf.append_extra(flow.orig.strip(), None)
def format_kv_list(self, buf, kv_list, full_str, style_obj, highlighted):
def format_kv_list(self, buf, kv_list, full_str, style_obj, highlighted,
omitted=None):
"""Format a KeyValue List.
Args:
@@ -260,10 +277,14 @@ class FlowFormatter:
full_str (str): the full string containing all k-v
style_obj (FlowStyle): a FlowStyle object to use
highlighted (list): Optional; list of KeyValues to highlight
highlighted (list): Optional; list of KeyValues to highlight
omitted (list): Optional; list of keys to omit
"""
for i, kv in enumerate(kv_list):
key_omitted = kv.key in omitted if omitted else False
written = self.format_kv(
buf, kv, style_obj=style_obj, highlighted=highlighted
buf, kv, style_obj=style_obj, highlighted=highlighted,
omitted=key_omitted
)
end = (
@@ -277,7 +298,7 @@ class FlowFormatter:
style=style_obj.get("default"),
)
def format_kv(self, buf, kv, style_obj, highlighted=None):
def format_kv(self, buf, kv, style_obj, highlighted=None, omitted=False):
"""Format a KeyValue
A formatted keyvalue has the following parts:
@@ -288,6 +309,7 @@ class FlowFormatter:
kv (KeyValue): The KeyValue to print
style_obj (FlowStyle): The style object to use
highlighted (list): Optional; list of KeyValues to highlight
omitted(boolean): Whether the value shall be omitted.
Returns the number of printed characters.
"""
@@ -308,9 +330,14 @@ class FlowFormatter:
buf.append_delim(kv, style_obj.get_delim_style(is_highlighted))
ret += len(kv.meta.delim)
value_style = style_obj.get_value_style(kv, is_highlighted)
buf.append_value(kv, value_style) # format value
ret += len(kv.meta.vstring)
if omitted:
buf.append_value_omitted(kv)
ret += len(kv.meta.vstring)
else:
value_style = style_obj.get_value_style(kv, is_highlighted)
buf.append_value(kv, value_style) # format value
ret += len(kv.meta.vstring)
if kv.meta.end_delim:
buf.append_end_delim(kv, style_obj.get_delim_style(is_highlighted))
@@ -362,6 +389,13 @@ class FlowBuffer:
"""
raise NotImplementedError
def append_value_omitted(self, kv):
"""Append an omitted value.
Args:
kv (KeyValue): the KeyValue instance to append
"""
raise NotImplementedError
def append_extra(self, extra, style):
"""Append extra string.
Args:

View File

@@ -15,6 +15,7 @@
import click
from ovs.flowviz.main import maincli
from ovs.flowviz.odp.tree import ConsoleTreeProcessor
from ovs.flowviz.process import (
ConsoleProcessor,
JSONDatapathProcessor,
@@ -54,3 +55,22 @@ def console(opts, heat_map):
)
proc.process()
proc.print()
@datapath.command()
@click.option(
"-h",
"--heat-map",
is_flag=True,
default=False,
show_default=True,
help="Create heat-map with packet and byte counters",
)
@click.pass_obj
def tree(opts, heat_map):
"""Print the flows in a tree based on the 'recirc_id'."""
processor = ConsoleTreeProcessor(
opts, heat_map=["packets", "bytes"] if heat_map else []
)
processor.process()
processor.print()

View File

@@ -0,0 +1,512 @@
# 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.
import sys
from rich.style import Style
from rich.console import Group
from rich.panel import Panel
from rich.text import Text
from rich.tree import Tree
from ovs.compat.sortedcontainers import SortedList
from ovs.flowviz.console import (
ConsoleFormatter,
ConsoleBuffer,
hash_pallete,
heat_pallete,
file_header,
)
from ovs.flowviz.process import (
FileProcessor,
)
class TreeFlow(object):
"""A flow within a Tree."""
def __init__(self, flow, filter=None):
self._flow = flow
self._visible = True
if filter:
self._matches = filter.evaluate(flow)
else:
self._matches = True
@property
def flow(self):
return self._flow
@property
def visible(self):
return self._visible
@visible.setter
def visible(self, new_visible):
self._visible = new_visible
@property
def matches(self):
return self._matches
class FlowBlock(object):
"""A block of flows in a Tree. Flows are arranged together in a block
if they have the same action.
"""
def __init__(self, tflow):
"""Create a FlowBlock based on a flow.
Args:
flow: TreeFlow
"""
self._flows = SortedList([], self.__key)
self._next_recirc_nodes = SortedList([], key=lambda x: -x.pkts)
self._actions = tflow.flow.actions_kv
self._sum_pkts = tflow.flow.info.get("packets") or 0
self._visible = False
self._flows.add(tflow)
self._equal_match = [
(i, kv)
for i, kv in enumerate(tflow.flow.match_kv)
if kv.key not in ["in_port", "recirc_id"]
]
in_port = tflow.flow.match.get("in_port")
self._next_recirc_inport = [
(recirc, in_port) for recirc in self._get_next_recirc(tflow.flow)
]
@property
def flows(self):
return self._flows
@property
def pkts(self):
return self._sum_pkts
@property
def visible(self):
return self._visible
@property
def equal_match(self):
return self._equal_match
@property
def next_recirc_nodes(self):
return self._next_recirc_nodes
def add_if_belongs(self, tflow):
"""Add TreeFlow to block if it belongs here."""
if not self._belongs(tflow):
return False
to_del = []
for i, (orig_i, kv) in enumerate(self.equal_match):
if orig_i >= len(tflow.flow.match_kv):
kv_i = None
else:
kv_i = tflow.flow.match_kv[orig_i]
if kv_i != kv:
to_del.append(i)
for i in sorted(to_del, reverse=True):
del self.equal_match[i]
self._sum_pkts += tflow.flow.info.get("packets") or 0
self._flows.add(tflow)
return True
def build(self, recirc_nodes):
"""Populates next_recirc_nodes given a dictionary of RecircNode objects
indexed by recirc_id and in_port.
"""
for recirc, in_port in self._next_recirc_inport:
try:
self._next_recirc_nodes.add(recirc_nodes[recirc][in_port])
except KeyError:
print(
f"mising [recirc_id {hex(recirc)} inport {in_port}]. "
"Flow tree will be incomplete.",
file=sys.stderr,
)
def compute_visible(self):
"""Determines if the block should be visible.
A FlowBlock is visible if any of its flows is.
If any of the nested RecircNodes is visible, all flows should be
visible. If not, only the ones that match should.
"""
nested_recirc_visible = False
for recirc in self._next_recirc_nodes:
recirc.compute_visible()
if recirc.visible:
nested_recirc_visible = True
for tflow in self._flows:
tflow.visible = True if nested_recirc_visible else tflow.matches
if tflow.visible:
self._visible = True
def _belongs(self, tflow):
if len(tflow.flow.actions_kv) != len(self._actions):
return False
return all(
[a == b for a, b in zip(tflow.flow.actions_kv, self._actions)]
)
def __key(self, f):
return -(f.flow.info.get("packets") or 0)
def _get_next_recirc(self, flow):
"""Get the next recirc_ids from a Flow.
The recirc_id is obtained from actions such as recirc, but also
complex actions such as check_pkt_len and sample
Args:
flow (ODPFlow): flow to get the recirc_id from.
Returns:
set of next recirculation ids.
"""
# Helper function to find a recirc in a dictionary of actions.
def find_in_list(actions_list):
recircs = []
for item in actions_list:
(action, value) = next(iter(item.items()))
if action == "recirc":
recircs.append(value)
elif action == "check_pkt_len":
recircs.extend(find_in_list(value.get("gt")))
recircs.extend(find_in_list(value.get("le")))
elif action == "clone":
recircs.extend(find_in_list(value))
elif action == "sample":
recircs.extend(find_in_list(value.get("actions")))
return recircs
recircs = []
recircs.extend(find_in_list(flow.actions))
return set(recircs)
class RecircNode(object):
def __init__(self, recirc, in_port, heat_map=[]):
self._recirc = recirc
self._in_port = in_port
self._visible = False
self._sum_pkts = 0
self._heat_map_fields = heat_map
self._min = dict.fromkeys(self._heat_map_fields, -1)
self._max = dict.fromkeys(self._heat_map_fields, 0)
self._blocks = []
self._sorted_blocks = SortedList([], key=lambda x: -x.pkts)
@property
def recirc(self):
return self._recirc
@property
def in_port(self):
return self._in_port
@property
def visible(self):
return self._visible
@property
def pkts(self):
"""Returns the blocks sorted by pkts.
Should not be called before running build()."""
return self._sum_pkts
@property
def min(self):
return self._min
@property
def max(self):
return self._max
def visible_blocks(self):
"""Returns visible blocks sorted by pkts.
Should not be called before running build()."""
return filter(lambda x: x.visible, self._sorted_blocks)
def add_flow(self, tflow):
assert tflow.flow.match.get("recirc_id") == self.recirc
assert tflow.flow.match.get("in_port") == self.in_port
self._sum_pkts += tflow.flow.info.get("packets") or 0
# Accumulate minimum and maximum values for later use in heat-map.
for field in self._heat_map_fields:
val = tflow.flow.info.get(field)
if self._min[field] == -1 or val < self._min[field]:
self._min[field] = val
if val > self._max[field]:
self._max[field] = val
for b in self._blocks:
if b.add_if_belongs(tflow):
return
self._blocks.append(FlowBlock(tflow))
def build(self, recirc_nodes):
"""Builds the recirculation links of nested blocks.
Args:
recirc_nodes: Dictionary of RecircNode objects indexed by
recirc_id and in_port.
"""
for block in self._blocks:
block.build(recirc_nodes)
self._sorted_blocks.add(block)
def compute_visible(self):
"""Determine if the RecircNode should be visible.
A RecircNode is visible if any of its blocks is.
"""
for block in self._blocks:
block.compute_visible()
if block.visible:
self._visible = True
class FlowTree:
"""A Flow tree is a a class that processes datapath flows into a tree based
on recirculation ids.
Args:
flows (list[ODPFlow]): Optional, initial list of flows
heat_map_fields (list[str]): Optional, info fields to calculate
maximum and minimum values.
"""
def __init__(self, flows=None, heat_map_fields=[]):
self._recirc_nodes = {}
self._all_recirc_nodes = []
self._heat_map_fields = heat_map_fields
if flows:
for flow in flows:
self.add(flow)
@property
def recirc_nodes(self):
"""Recirculation nodes in a double-dictionary.
First-level key: recirc_id. Second-level key: in_port.
"""
return self._recirc_nodes
@property
def all_recirc_nodes(self):
"""All Recirculation nodes in a list."""
return self._all_recirc_nodes
def add(self, flow, filter=None):
"""Add a flow"""
rid = flow.match.get("recirc_id") or 0
in_port = flow.match.get("in_port") or 0
if not self._recirc_nodes.get(rid):
self._recirc_nodes[rid] = {}
if not self._recirc_nodes.get(rid).get(in_port):
node = RecircNode(rid, in_port, heat_map=self._heat_map_fields)
self._recirc_nodes[rid][in_port] = node
self._all_recirc_nodes.append(node)
self._recirc_nodes[rid][in_port].add_flow(TreeFlow(flow, filter))
def build(self):
"""Build the flow tree."""
for node in self._all_recirc_nodes:
node.build(self._recirc_nodes)
# Once recirculation links have been built. Determine what should stay
# visible recursively starting by recirc_id = 0.
for _, node in self._recirc_nodes.get(0).items():
node.compute_visible()
def min_max(self):
"""Return a dictionary, indexed by the heat_map_fields, of minimum
and maximum values.
"""
min_vals = {field: [] for field in self._heat_map_fields}
max_vals = {field: [] for field in self._heat_map_fields}
if not self._heat_map_fields:
return None
for node in self._all_recirc_nodes:
if not node.visible:
continue
for field in self._heat_map_fields:
min_vals[field].append(node.min[field])
max_vals[field].append(node.max[field])
return {
field: (
min(min_vals[field]) if min_vals[field] else 0,
max(max_vals[field]) if max_vals[field] else 0,
)
for field in self._heat_map_fields
}
class ConsoleTreeProcessor(FileProcessor):
def __init__(self, opts, heat_map=[]):
super().__init__(opts, "odp")
self.trees = {}
self.ofconsole = ConsoleFormatter(self.opts)
self.style = self.ofconsole.style
self.heat_map = heat_map
self.tree = None
self.curr_file = ""
if self.style:
# Generate a color pallete for recirc ids.
self.recirc_style_gen = hash_pallete(
hue=[x / 50 for x in range(0, 50)],
saturation=[0.7],
value=[0.8],
)
self.style.set_default_value_style(Style(color="grey66"))
self.style.set_key_style("output", Style(color="green"))
self.style.set_value_style("output", Style(color="green"))
self.style.set_value_style("recirc", self.recirc_style_gen)
self.style.set_value_style("recirc_id", self.recirc_style_gen)
def start_file(self, name, filename):
self.tree = FlowTree(heat_map_fields=self.heat_map)
self.curr_file = name
def start_thread(self, name):
if not self.tree:
self.tree = FlowTree(heat_map_fields=self.heat_map)
def stop_thread(self, name):
full_name = self.curr_file + f" ({name})"
if self.tree:
self.trees[full_name] = self.tree
self.tree = None
def process_flow(self, flow, name):
self.tree.add(flow, self.opts.get("filter"))
def process(self):
super().process(False)
def stop_file(self, name, filename):
if self.tree:
self.trees[name] = self.tree
self.tree = None
def print(self):
for name, tree in self.trees.items():
self.ofconsole.console.print("\n")
self.ofconsole.console.print(file_header(name))
tree.build()
if self.style:
min_max = tree.min_max()
for field in self.heat_map:
min_val, max_val = min_max[field]
self.style.set_value_style(
field, heat_pallete(min_val, max_val)
)
self.print_tree(tree)
def print_tree(self, tree):
root = Tree("Datapath Flows (logical)")
# Start by shoing recirc_id = 0
for in_port in sorted(tree.recirc_nodes[0].keys()):
node = tree.recirc_nodes[0][in_port]
if node.visible:
self.print_recirc_node(root, node)
self.ofconsole.console.print(root)
def print_recirc_node(self, parent, node):
if self.ofconsole.style:
recirc_style = self.recirc_style_gen(hex(node.recirc))
else:
recirc_style = None
node_text = Text(
"[recirc_id({}) in_port({})]".format(
hex(node.recirc), node.in_port
),
style=recirc_style,
)
console_node = parent.add(
Panel.fit(node_text), guide_style=recirc_style
)
for block in node.visible_blocks():
self.print_block(block, console_node)
def print_block(self, block, parent):
# Print the flow matches and the statistics.
flow_text = []
omit_first = {
"actions": "all",
}
omit_rest = {
"actions": "all",
"match": [kv.key for _, kv in block.equal_match],
}
for i, flow in enumerate(filter(lambda x: x.visible, block.flows)):
omit = omit_rest if i > 0 else omit_first
buf = ConsoleBuffer(Text())
self.ofconsole.format_flow(buf, flow.flow, omitted=omit)
flow_text.append(buf.text)
# Print the action associated with the block.
omit = {
"match": "all",
"info": "all",
"ufid": "all",
"dp_extra_info": "all",
}
act_buf = ConsoleBuffer(Text())
act_buf.append_extra("actions: ", Style(bold=(self.style is not None)))
self.ofconsole.format_flow(act_buf, block.flows[0].flow, omitted=omit)
flows_node = parent.add(
Panel(Group(*flow_text)), guide_style=Style(color="default")
)
action_node = flows_node.add(
Panel.fit(
act_buf.text, border_style="green" if self.style else "default"
),
guide_style=Style(color="default"),
)
# Nested to the action, print the next recirc nodes.
for node in block.next_recirc_nodes:
if node.visible:
self.print_recirc_node(action_node, node)