diff mbox series

[ovs-dev,v2,02/12] python: ovs: flowviz: Add file processing infra.

Message ID 20240313090334.414226-3-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
process.py contains a useful base class that processes files
odp.py and ofp.py: contain datapath and openflow subcommand definitions
as well as the first formatting option: json.

Also, this patch adds basic filtering support.

Examples:
$ ovs-ofctl dump-flows br-int | ovs-flowviz openflow json
$ ovs-ofctl dump-flows br-int > flows.txt && ovs-flowviz -i flows.txt openflow json
$ ovs-ofctl appctl dpctl/dump-flows | ovs-flowviz -f 'ct' datapath json
$ ovs-ofctl appctl dpctl/dump-flows > flows.txt && ovs-flowviz -i flows.txt -f 'drop' datapath json

Signed-off-by: Adrian Moreno <amorenoz@redhat.com>
Acked-by: Eelco Chaudron <echaudro@redhat.com>
---
 python/automake.mk             |   5 +-
 python/ovs/flowviz/__init__.py |   2 +
 python/ovs/flowviz/main.py     | 102 +++++++++++++++++-
 python/ovs/flowviz/odp/cli.py  |  42 ++++++++
 python/ovs/flowviz/ofp/cli.py  |  42 ++++++++
 python/ovs/flowviz/process.py  | 192 +++++++++++++++++++++++++++++++++
 6 files changed, 383 insertions(+), 2 deletions(-)
 create mode 100644 python/ovs/flowviz/odp/cli.py
 create mode 100644 python/ovs/flowviz/ofp/cli.py
 create mode 100644 python/ovs/flowviz/process.py
diff mbox series

Patch

diff --git a/python/automake.mk b/python/automake.mk
index 124032c92..fd5e74081 100644
--- a/python/automake.mk
+++ b/python/automake.mk
@@ -67,8 +67,11 @@  ovs_flowviz = \
 	python/ovs/flowviz/__init__.py \
 	python/ovs/flowviz/main.py \
 	python/ovs/flowviz/odp/__init__.py \
+	python/ovs/flowviz/odp/cli.py \
 	python/ovs/flowviz/ofp/__init__.py \
-	python/ovs/flowviz/ovs-flowviz
+	python/ovs/flowviz/ofp/cli.py \
+	python/ovs/flowviz/ovs-flowviz \
+	python/ovs/flowviz/process.py
 
 
 # These python files are used at build time but not runtime,
diff --git a/python/ovs/flowviz/__init__.py b/python/ovs/flowviz/__init__.py
index e69de29bb..898dba522 100644
--- a/python/ovs/flowviz/__init__.py
+++ b/python/ovs/flowviz/__init__.py
@@ -0,0 +1,2 @@ 
+import ovs.flowviz.ofp.cli  # noqa: F401
+import ovs.flowviz.odp.cli  # noqa: F401
diff --git a/python/ovs/flowviz/main.py b/python/ovs/flowviz/main.py
index f5bf142be..64b0e8a0a 100644
--- a/python/ovs/flowviz/main.py
+++ b/python/ovs/flowviz/main.py
@@ -13,17 +13,64 @@ 
 # limitations under the License.
 
 import click
+import os
+
+from ovs.flow.filter import OFFilter
 
 
 class Options(dict):
     """Options dictionary"""
 
 
+def validate_input(ctx, param, value):
+    """Validate the "-i" option"""
+    result = list()
+    for input_str in value:
+        parts = input_str.strip().split(",")
+        if len(parts) == 2:
+            file_parts = tuple(parts)
+        elif len(parts) == 1:
+            file_parts = tuple(["Filename: " + parts[0], parts[0]])
+        else:
+            raise click.BadParameter(
+                "input filename should have the following format: "
+                "[alias,]FILENAME"
+            )
+
+        if not os.path.isfile(file_parts[1]):
+            raise click.BadParameter(
+                "input filename %s does not exist" % file_parts[1]
+            )
+        result.append(file_parts)
+    return result
+
+
 @click.group(
     context_settings=dict(help_option_names=["-h", "--help"]),
 )
+@click.option(
+    "-i",
+    "--input",
+    "filename",
+    help="Read flows from specified filepath. If not provided, flows will be"
+    " read from stdin. This option can be specified multiple times."
+    " Format [alias,]FILENAME. Where alias is a name that shall be used to"
+    " refer to this FILENAME",
+    multiple=True,
+    type=click.Path(),
+    callback=validate_input,
+)
+@click.option(
+    "-f",
+    "--filter",
+    help="Filter flows that match the filter expression."
+    "Run 'ovs-flowviz filter' for a detailed description of the filtering "
+    "syntax",
+    type=str,
+    show_default=False,
+)
 @click.pass_context
-def maincli(ctx):
+def maincli(ctx, filename, filter):
     """
     OpenvSwitch flow visualization utility.
 
@@ -31,6 +78,59 @@  def maincli(ctx):
     (such as the output of ovs-ofctl dump-flows or ovs-appctl dpctl/dump-flows)
     and prints them in different formats.
     """
+    ctx.obj = Options()
+    ctx.obj["filename"] = filename or None
+    if filter:
+        try:
+            ctx.obj["filter"] = OFFilter(filter)
+        except Exception as e:
+            raise click.BadParameter("Wrong filter syntax: {}".format(e))
+
+
+@maincli.command(hidden=True)
+@click.pass_context
+def filter(ctx):
+    """
+    \b
+    Filter Syntax
+    *************
+
+     [! | not ] {key}[[.subkey]...] [OPERATOR] {value})] [LOGICAL OPERATOR] ...
+
+    \b
+    Comparison operators are:
+        =   equality
+        <   less than
+        >   more than
+        ~=  masking (valid for IP and Ethernet fields)
+
+    \b
+    Logical operators are:
+        !{expr}:  NOT
+        {expr} && {expr}: AND
+        {expr} || {expr}: OR
+
+    \b
+    Matches and flow metadata:
+        To compare against a match or info field, use the field directly, e.g:
+            priority=100
+            n_bytes>10
+        Use simple keywords for flags:
+            tcp and ip_src=192.168.1.1
+    \b
+    Actions:
+        Actions values might be dictionaries, use subkeys to access individual
+        values, e.g:
+            output.port=3
+        Use simple keywords for flags
+            drop
+
+    \b
+    Examples of valid filters.
+        nw_addr~=192.168.1.1 && (tcp_dst=80 || tcp_dst=443)
+        arp=true && !arp_tsa=192.168.1.1
+        n_bytes>0 && drop=true"""
+    click.echo(ctx.command.get_help(ctx))
 
 
 def main():
diff --git a/python/ovs/flowviz/odp/cli.py b/python/ovs/flowviz/odp/cli.py
new file mode 100644
index 000000000..ed2f82065
--- /dev/null
+++ b/python/ovs/flowviz/odp/cli.py
@@ -0,0 +1,42 @@ 
+# 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 click
+
+from ovs.flowviz.main import maincli
+from ovs.flowviz.process import (
+    DatapathFactory,
+    JSONProcessor,
+)
+
+
+@maincli.group(subcommand_metavar="FORMAT")
+@click.pass_obj
+def datapath(opts):
+    """Process Datapath Flows."""
+    pass
+
+
+class JSONPrint(DatapathFactory, JSONProcessor):
+    def __init__(self, opts):
+        super().__init__(opts)
+
+
+@datapath.command()
+@click.pass_obj
+def json(opts):
+    """Print the flows in JSON format."""
+    proc = JSONPrint(opts)
+    proc.process()
+    print(proc.json_string())
diff --git a/python/ovs/flowviz/ofp/cli.py b/python/ovs/flowviz/ofp/cli.py
new file mode 100644
index 000000000..b9a2a8aad
--- /dev/null
+++ b/python/ovs/flowviz/ofp/cli.py
@@ -0,0 +1,42 @@ 
+# 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 click
+
+from ovs.flowviz.main import maincli
+from ovs.flowviz.process import (
+    OpenFlowFactory,
+    JSONProcessor,
+)
+
+
+@maincli.group(subcommand_metavar="FORMAT")
+@click.pass_obj
+def openflow(opts):
+    """Process OpenFlow Flows."""
+    pass
+
+
+class JSONPrint(OpenFlowFactory, JSONProcessor):
+    def __init__(self, opts):
+        super().__init__(opts)
+
+
+@openflow.command()
+@click.pass_obj
+def json(opts):
+    """Print the flows in JSON format."""
+    proc = JSONPrint(opts)
+    proc.process()
+    print(proc.json_string())
diff --git a/python/ovs/flowviz/process.py b/python/ovs/flowviz/process.py
new file mode 100644
index 000000000..3e520e431
--- /dev/null
+++ b/python/ovs/flowviz/process.py
@@ -0,0 +1,192 @@ 
+# 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 json
+import click
+
+from ovs.flow.decoders import FlowEncoder
+from ovs.flow.odp import ODPFlow
+from ovs.flow.ofp import OFPFlow
+
+
+class FileProcessor(object):
+    """Base class for file-based Flow processing. It is able to create flows
+    from strings found in a file (or stdin).
+
+    The process of parsing the flows is extendable in many ways by deriving
+    this class.
+
+    When process() is called, the base class will:
+        - call self.start_file() for each new file that get's processed
+        - call self.create_flow() for each flow line
+        - apply the filter defined in opts if provided (can be optionally
+            disabled)
+        - call self.process_flow() for after the flow has been filtered
+        - call self.stop_file() after the file has been processed entirely
+
+    In the case of stdin, the filename and file alias is 'stdin'.
+
+    Child classes must at least implement create_flow() and process_flow()
+    functions.
+
+    Args:
+        opts (dict): Options dictionary
+    """
+
+    def __init__(self, opts):
+        self.opts = opts
+
+    # Methods that must be implemented by derived classes.
+    def init(self):
+        """Called before the flow processing begins."""
+        pass
+
+    def start_file(self, alias, filename):
+        """Called before the processing of a file begins.
+        Args:
+            alias(str): The alias name of the filename
+            filename(str): The filename string
+        """
+        pass
+
+    def create_flow(self, line, idx):
+        """Called for each line in the file.
+        Args:
+            line(str): The flow line
+            idx(int): The line index
+
+        Returns a Flow.
+        Must be implemented by child classes.
+        """
+        raise NotImplementedError
+
+    def process_flow(self, flow, name):
+        """Called for built flow (after filtering).
+        Args:
+            flow(Flow): The flow created by create_flow
+            name(str): The name of the file from which the flow comes
+        """
+        raise NotImplementedError
+
+    def stop_file(self, alias, filename):
+        """Called after the processing of a file ends.
+        Args:
+            alias(str): The alias name of the filename
+            filename(str): The filename string
+        """
+        pass
+
+    def end(self):
+        """Called after the processing ends."""
+        pass
+
+    def process(self, do_filter=True):
+        idx = 0
+        filenames = self.opts.get("filename")
+        filt = self.opts.get("filter") if do_filter else None
+        self.init()
+        if filenames:
+            for alias, filename in filenames:
+                try:
+                    with open(filename) as f:
+                        self.start_file(alias, filename)
+                        for line in f:
+                            flow = self.create_flow(line, idx)
+                            idx += 1
+                            if not flow or (filt and not filt.evaluate(flow)):
+                                continue
+                            self.process_flow(flow, alias)
+                        self.stop_file(alias, filename)
+                except IOError as e:
+                    raise click.BadParameter(
+                        "Failed to read from file {} ({}): {}".format(
+                            filename, e.errno, e.strerror
+                        )
+                    )
+        else:
+            data = sys.stdin.read()
+            self.start_file("stdin", "stdin")
+            for line in data.split("\n"):
+                line = line.strip()
+                if line:
+                    flow = self.create_flow(line, idx)
+                    idx += 1
+                    if (
+                        not flow
+                        or not getattr(flow, "_sections", None)
+                        or (filt and not filt.evaluate(flow))
+                    ):
+                        continue
+                    self.process_flow(flow, "stdin")
+            self.stop_file("stdin", "stdin")
+        self.end()
+
+
+class DatapathFactory():
+    """A mixin class that creates Datapath flows."""
+
+    def create_flow(self, line, idx):
+        # Skip strings commonly found in Datapath flow dumps.
+        if any(s in line for s in [
+            "flow-dump from the main thread",
+            "flow-dump from pmd on core",
+        ]):
+            return None
+
+        return ODPFlow(line, idx)
+
+
+class OpenFlowFactory():
+    """A mixin class that creates OpenFlow flows."""
+
+    def create_flow(self, line, idx):
+        # Skip strings commonly found in OpenFlow flow dumps.
+        if " reply " in line:
+            return None
+
+        return OFPFlow(line, idx)
+
+
+class JSONProcessor(FileProcessor):
+    """A FileProcessor that prints flows in JSON format."""
+
+    def __init__(self, opts):
+        super().__init__(opts)
+        self.flows = dict()
+
+    def start_file(self, name, filename):
+        self.flows_list = list()
+
+    def stop_file(self, name, filename):
+        self.flows[name] = self.flows_list
+
+    def process_flow(self, flow, name):
+        self.flows_list.append(flow)
+
+    def json_string(self):
+        if len(self.flows.keys()) > 1:
+            return json.dumps(
+                [
+                    {"name": name, "flows": [flow.dict() for flow in flows]}
+                    for name, flows in self.flows.items()
+                ],
+                indent=4,
+                cls=FlowEncoder,
+            )
+        return json.dumps(
+            [flow.dict() for flow in self.flows_list],
+            indent=4,
+            cls=FlowEncoder,
+        )