diff mbox series

[ovs-dev,v2,07/12] python: ovs: flowviz: Add OpenFlow logical view.

Message ID 20240313090334.414226-8-amorenoz@redhat.com
State Superseded
Headers show
Series Add flow visualization utility. | expand

Checks

Context Check Description
ovsrobot/apply-robot success apply and check: success
ovsrobot/github-robot-_Build_and_Test success github build: passed
ovsrobot/intel-ovs-compilation success test: success

Commit Message

Adrian Moreno March 13, 2024, 9:03 a.m. UTC
This view is interesting for debugging the logical pipeline. It arranges
the flows in "logical" groups (not to be confused with OVN's
Logical_Flows). A logical group of flows is a set of flows that:
- Have the same table number and priority
- Match on the same fields (regardless of the value they match against)
- Have the same actions, regardless of the arguments for those actions,
  except for output and recirc, for which arguments do care.

Optionally, the cookie can also be force to be unique for the logical
group. By doing so, we can extend the information we show by querying an
external OVN database and running "ovn-detrace" on each cookie. The
result is a compact list of flow groups with interlieved OVN
information.

Furthermore, if connected to an OVN database, we can apply an OVN
regexp filter.

Examples:
$ ovs-ofctl dump-flows br-int | ovs-flowviz openflow logic
$ ovs-ofctl dump-flows br-int | ovs-flowviz openflow logic -s -h
$ export OVN_NB_DB=...
$ export OVN_SB_DB=...
$ ovs-ofctl dump-flows br-int | ovs-flowviz openflow logic -d
$ ovs-ofctl dump-flows br-int | ovs-flowviz openflow logic -d
--ovn-filter="acl.*icmp4"

Acked-by: Eelco Chaudron <echaudro@redhat.com>
Signed-off-by: Adrian Moreno <amorenoz@redhat.com>
---
 python/automake.mk              |   4 +-
 python/ovs/flowviz/ofp/cli.py   | 113 ++++++++++++
 python/ovs/flowviz/ofp/logic.py | 303 ++++++++++++++++++++++++++++++++
 3 files changed, 418 insertions(+), 2 deletions(-)
 create mode 100644 python/ovs/flowviz/ofp/logic.py
diff mbox series

Patch

diff --git a/python/automake.mk b/python/automake.mk
index b3fef9bed..449daf023 100644
--- a/python/automake.mk
+++ b/python/automake.mk
@@ -74,12 +74,12 @@  ovs_flowviz = \
 	python/ovs/flowviz/odp/tree.py \
 	python/ovs/flowviz/ofp/__init__.py \
 	python/ovs/flowviz/ofp/cli.py \
+	python/ovs/flowviz/ofp/logic.py \
 	python/ovs/flowviz/ofp/html.py \
 	python/ovs/flowviz/ovs-flowviz \
 	python/ovs/flowviz/process.py
 
-# These python files are used at build time but not runtime,
-# so they are not installed.
+# These python files are used at build time but not runtime, so they are not installed.
 EXTRA_DIST += \
 	python/ovs_build_helpers/__init__.py \
 	python/ovs_build_helpers/extract_ofp_fields.py \
diff --git a/python/ovs/flowviz/ofp/cli.py b/python/ovs/flowviz/ofp/cli.py
index 2cd8e1c89..51428ede0 100644
--- a/python/ovs/flowviz/ofp/cli.py
+++ b/python/ovs/flowviz/ofp/cli.py
@@ -12,10 +12,13 @@ 
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import os
+
 import click
 
 from ovs.flowviz.main import maincli
 from ovs.flowviz.ofp.html import HTMLProcessor
+from ovs.flowviz.ofp.logic import LogicFlowProcessor
 from ovs.flowviz.process import (
     ConsoleProcessor,
     OpenFlowFactory,
@@ -69,6 +72,116 @@  def console(opts, heat_map):
     proc.print()
 
 
+def ovn_detrace_callback(ctx, param, value):
+    """click callback to add detrace information to config object and
+    set general ovn-detrace flag to True
+    """
+    ctx.obj[param.name] = value
+    if value != param.default:
+        ctx.obj["ovn_detrace_flag"] = True
+    return value
+
+
+@openflow.command()
+@click.option(
+    "-d",
+    "--ovn-detrace",
+    "ovn_detrace_flag",
+    is_flag=True,
+    show_default=True,
+    help="Use ovn-detrace to extract cookie information (implies '-c')",
+)
+@click.option(
+    "--ovn-detrace-path",
+    default="/usr/bin",
+    type=click.Path(),
+    help="Use an alternative path to where ovn_detrace.py is located. "
+    "Instead of using this option you can just set PYTHONPATH accordingly.",
+    show_default=True,
+    callback=ovn_detrace_callback,
+)
+@click.option(
+    "--ovnnb-db",
+    default=os.getenv("OVN_NB_DB") or "unix:/var/run/ovn/ovnnb_db.sock",
+    help="Specify the OVN NB database string (implies -d). "
+    "If the OVN_NB_DB environment variable is set, it's used as default. "
+    "Otherwise, the default is unix:/var/run/ovn/ovnnb_db.sock",
+    callback=ovn_detrace_callback,
+)
+@click.option(
+    "--ovnsb-db",
+    default=os.getenv("OVN_SB_DB") or "unix:/var/run/ovn/ovnsb_db.sock",
+    help="Specify the OVN NB database string (implies -d). "
+    "If the OVN_NB_DB environment variable is set, it's used as default. "
+    "Otherwise, the default is unix:/var/run/ovn/ovnnb_db.sock",
+    callback=ovn_detrace_callback,
+)
+@click.option(
+    "-o",
+    "--ovn-filter",
+    help="Specify a filter to be run on ovn-detrace information (implied -d). "
+    "Format: python regular expression "
+    "(see https://docs.python.org/3/library/re.html)",
+    callback=ovn_detrace_callback,
+)
+@click.option(
+    "-s",
+    "--show-flows",
+    is_flag=True,
+    default=False,
+    show_default=True,
+    help="Show the full flows under each logical flow",
+)
+@click.option(
+    "-c",
+    "--cookie",
+    "cookie_flag",
+    is_flag=True,
+    default=False,
+    show_default=True,
+    help="Consider the cookie in the logical flow",
+)
+@click.option(
+    "-h",
+    "--heat-map",
+    is_flag=True,
+    default=False,
+    show_default=True,
+    help="Create heat-map with packet and byte counters (when -s is used)",
+)
+@click.pass_obj
+def logic(
+    opts,
+    ovn_detrace_flag,
+    ovn_detrace_path,
+    ovnnb_db,
+    ovnsb_db,
+    ovn_filter,
+    show_flows,
+    cookie_flag,
+    heat_map,
+):
+    """
+    Print the logical structure of the flows.
+
+    First, sorts the flows based on tables and priorities.
+    Then, deduplicates logically equivalent flows: these a flows that match
+    on the same set of fields (regardless of the values they match against),
+    have the same priority, and actions (regardless of action arguments,
+    except in the case of output and recirculate).
+    Optionally, the cookie can also be considered to be part of the logical
+    flow.
+    """
+    if ovn_detrace_flag:
+        opts["ovn_detrace_flag"] = True
+    if opts.get("ovn_detrace_flag"):
+        cookie_flag = True
+
+    processor = LogicFlowProcessor(opts, cookie_flag, heat_map)
+    processor.process()
+    processor.print(show_flows)
+
+
 @openflow.command()
 @click.pass_obj
 def html(opts):
diff --git a/python/ovs/flowviz/ofp/logic.py b/python/ovs/flowviz/ofp/logic.py
new file mode 100644
index 000000000..db2124374
--- /dev/null
+++ b/python/ovs/flowviz/ofp/logic.py
@@ -0,0 +1,303 @@ 
+# 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
+import io
+import re
+
+from rich.tree import Tree
+from rich.text import Text
+
+from ovs.flowviz.process import FileProcessor, OpenFlowFactory
+from ovs.flowviz.console import (
+    ConsoleFormatter,
+    ConsoleBuffer,
+    hash_pallete,
+    file_header,
+    heat_pallete,
+)
+
+
+class LFlow:
+    """A Logical Flow represents the scheleton of a flow.
+
+    Two logical flows have the same logical representation if they match
+    against same fields (regardless of the matching value) and have the same
+    set of actions (regardless of the actions' arguments, except for those
+    in the exact_actions list).
+
+    Attributes:
+        flow (OFPFlow): The flow
+        exact_actions(list): Optional; list of action keys that are
+            considered unique if the value is also the same.
+        match_cookie (bool): Optional; if cookies are part of the logical
+            flow
+    """
+
+    def __init__(self, flow, exact_actions=[], match_cookie=False):
+        self.cookie = flow.info.get("cookie") or 0 if match_cookie else None
+        self.priority = flow.match.get("priority") or 0
+        self.match_keys = tuple([kv.key for kv in flow.match_kv])
+
+        self.action_keys = tuple(
+            [
+                kv.key
+                for kv in flow.actions_kv
+                if kv.key not in exact_actions
+            ]
+        )
+        self.match_action_kvs = [
+            kv for kv in flow.actions_kv if kv.key in exact_actions
+        ]
+
+    def __eq__(self, other):
+        return (
+            (self.cookie == other.cookie if self.cookie else True)
+            and self.priority == other.priority
+            and self.action_keys == other.action_keys
+            and self.equal_match_action_kvs(other)
+            and self.match_keys == other.match_keys
+        )
+
+    def equal_match_action_kvs(self, other):
+        """ Compares the logical flow's match action key-values with the
+        others.
+
+        Args:
+            other (LFlow): The other LFlow to compare against
+
+        Returns true if both LFlow have the same action k-v.
+        """
+        if len(other.match_action_kvs) != len(self.match_action_kvs):
+            return False
+
+        for kv in self.match_action_kvs:
+            found = False
+            for other_kv in other.match_action_kvs:
+                if self.match_kv(kv, other_kv):
+                    found = True
+                    break
+            if not found:
+                return False
+        return True
+
+    def match_kv(self, one, other):
+        """Compares a KeyValue.
+        Args:
+            one, other (KeyValue): The objects to compare
+
+        Returns true if both KeyValue objects have the same key and value
+        """
+        return one.key == other.key and one.value == other.value
+
+    def __hash__(self):
+        hash_data = [
+            self.cookie,
+            self.priority,
+            self.action_keys,
+            tuple((kv.key, str(kv.value)) for kv in self.match_action_kvs),
+            self.match_keys,
+        ]
+        if self.cookie:
+            hash_data.append(self.cookie)
+        return tuple(hash_data).__hash__()
+
+    def format(self, buf, formatter):
+        """Format the Logical Flow into a Buffer."""
+        if self.cookie:
+            buf.append_extra(
+                "cookie={} ".format(hex(self.cookie)).ljust(18),
+                style=cookie_style_gen(str(self.cookie)),
+            )
+
+        buf.append_extra(
+            "priority={} ".format(self.priority), style="steel_blue"
+        )
+        buf.append_extra(",".join(self.match_keys), style="steel_blue")
+        buf.append_extra("  --->  ", style="bold magenta")
+        buf.append_extra(",".join(self.action_keys), style="steel_blue")
+
+        if len(self.match_action_kvs) > 0:
+            buf.append_extra(" ", style=None)
+
+        for kv in self.match_action_kvs:
+            formatter.format_kv(buf, kv, formatter.style)
+            buf.append_extra(",", style=None)
+
+
+class LogicFlowProcessor(OpenFlowFactory, FileProcessor):
+    def __init__(self, opts, match_cookie, heat_map):
+        super().__init__(opts)
+        self.data = dict()
+        self.min_max = dict()
+        self.match_cookie = match_cookie
+        self.heat_map = ["n_packets", "n_bytes"] if heat_map else []
+        self.ovn_detrace = (
+            OVNDetrace(opts) if opts.get("ovn_detrace_flag") else None
+        )
+
+    def start_file(self, name, filename):
+        if len(self.heat_map) > 0:
+            self.min = [-1] * len(self.heat_map)
+            self.max = [0] * len(self.heat_map)
+        self.tables = dict()
+
+    def stop_file(self, name, filename):
+        if len(self.heat_map) > 0:
+            self.min_max[name] = (self.min, self.max)
+        self.data[name] = self.tables
+
+    def process_flow(self, flow, name):
+        """Sort the flows by table and logical flow."""
+        # Running calculation of min and max values for all the fields that
+        # take place in the heatmap.
+        for i, field in enumerate(self.heat_map):
+            val = flow.info.get(field)
+            if self.min[i] == -1 or val < self.min[i]:
+                self.min[i] = val
+            if val > self.max[i]:
+                self.max[i] = val
+
+        table = flow.info.get("table") or 0
+        if not self.tables.get(table):
+            self.tables[table] = dict()
+
+        # Group flows by logical hash
+        lflow = LFlow(
+            flow,
+            exact_actions=["output", "resubmit", "drop"],
+            match_cookie=self.match_cookie,
+        )
+
+        if not self.tables[table].get(lflow):
+            self.tables[table][lflow] = list()
+
+        self.tables[table][lflow].append(flow)
+
+    def print(self, show_flows):
+        formatter = ConsoleFormatter(opts=self.opts)
+        console = formatter.console
+        for name, tables in self.data.items():
+            console.print("\n")
+            console.print(file_header(name))
+            tree = Tree("Ofproto Flows (logical)")
+
+            for table_num in sorted(tables.keys()):
+                table = tables[table_num]
+                table_tree = tree.add("** TABLE {} **".format(table_num))
+
+                if len(self.heat_map) > 0 and len(table.values()) > 0:
+                    for i, field in enumerate(self.heat_map):
+                        (min_val, max_val) = self.min_max[name][i]
+                        formatter.style.set_value_style(
+                            field, heat_pallete(min_val, max_val)
+                        )
+
+                for lflow in sorted(
+                    table.keys(),
+                    key=(lambda x: x.priority),
+                    reverse=True,
+                ):
+                    flows = table[lflow]
+                    ovn_info = None
+                    if self.ovn_detrace:
+                        ovn_info = self.ovn_detrace.get_ovn_info(lflow.cookie)
+                        if self.opts.get("ovn_filter"):
+                            ovn_regexp = re.compile(
+                                self.opts.get("ovn_filter")
+                            )
+                            if not ovn_regexp.search(ovn_info):
+                                continue
+
+                    buf = ConsoleBuffer(Text())
+
+                    lflow.format(buf, formatter)
+                    buf.append_extra(
+                        " ( x {} )".format(len(flows)),
+                        style="dark_olive_green3",
+                    )
+                    lflow_tree = table_tree.add(buf.text)
+
+                    if ovn_info:
+                        ovn = lflow_tree.add("OVN Info")
+                        for part in ovn_info.split("\n"):
+                            if part.strip():
+                                ovn.add(part.strip())
+
+                    if show_flows:
+                        for flow in flows:
+                            buf = ConsoleBuffer(Text())
+                            highlighted = None
+                            if self.opts.get("highlight"):
+                                result = self.opts.get("highlight").evaluate(
+                                    flow
+                                )
+                                if result:
+                                    highlighted = result.kv
+                            formatter.format_flow(buf, flow, highlighted)
+                            lflow_tree.add(buf.text)
+
+            console.print(tree)
+
+
+class OVNDetrace(object):
+    def __init__(self, opts):
+        if not opts.get("ovn_detrace_flag"):
+            raise Exception("Cannot initialize OVN Detrace connection")
+
+        if opts.get("ovn_detrace_path"):
+            sys.path.append(opts.get("ovn_detrace_path"))
+
+        import ovn_detrace
+
+        class FakePrinter(ovn_detrace.Printer):
+            def __init__(self):
+                self.buff = io.StringIO()
+
+            def print_p(self, msg):
+                print("  * ", msg, file=self.buff)
+
+            def print_h(self, msg):
+                print("   * ", msg, file=self.buff)
+
+            def clear(self):
+                self.buff = io.StringIO()
+
+        self.ovn_detrace = ovn_detrace
+        self.ovnnb_conn = ovn_detrace.OVSDB(
+            opts.get("ovnnb_db"), "OVN_Northbound"
+        )
+        self.ovnsb_conn = ovn_detrace.OVSDB(
+            opts.get("ovnsb_db"), "OVN_Southbound"
+        )
+        self.ovn_printer = FakePrinter()
+        self.cookie_handlers = ovn_detrace.get_cookie_handlers(
+            self.ovnnb_conn, self.ovnsb_conn, self.ovn_printer
+        )
+
+    def get_ovn_info(self, cookie):
+        self.ovn_printer.clear()
+        self.ovn_detrace.print_record_from_cookie(
+            self.ovnsb_conn, self.cookie_handlers, "{:x}".format(cookie)
+        )
+        return self.ovn_printer.buff.getvalue()
+
+
+# Try to make it easy to spot same cookies by printing them in different
+# colors
+cookie_style_gen = hash_pallete(
+    hue=[x / 10 for x in range(0, 10)],
+    saturation=[0.5],
+    value=[0.5 + x / 10 * (0.85 - 0.5) for x in range(0, 10)],
+)