mirror of
https://github.com/openvswitch/ovs
synced 2025-09-03 07:45:30 +00:00
python: ovs: flowviz: Add file processing infra.
process.py contains a useful base class that processes files. Datapath flow processing is pmd-thread-aware. 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 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:
committed by
Ilya Maximets
parent
2fb2dbe827
commit
e3149d4808
@@ -67,8 +67,11 @@ ovs_flowviz = \
|
|||||||
python/ovs/flowviz/__init__.py \
|
python/ovs/flowviz/__init__.py \
|
||||||
python/ovs/flowviz/main.py \
|
python/ovs/flowviz/main.py \
|
||||||
python/ovs/flowviz/odp/__init__.py \
|
python/ovs/flowviz/odp/__init__.py \
|
||||||
|
python/ovs/flowviz/odp/cli.py \
|
||||||
python/ovs/flowviz/ofp/__init__.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,
|
# These python files are used at build time but not runtime,
|
||||||
|
@@ -0,0 +1,2 @@
|
|||||||
|
import ovs.flowviz.ofp.cli # noqa: F401
|
||||||
|
import ovs.flowviz.odp.cli # noqa: F401
|
||||||
|
@@ -13,17 +13,64 @@
|
|||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
import os
|
||||||
|
|
||||||
|
from ovs.flow.filter import OFFilter
|
||||||
|
|
||||||
|
|
||||||
class Options(dict):
|
class Options(dict):
|
||||||
"""Options dictionary"""
|
"""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(
|
@click.group(
|
||||||
context_settings=dict(help_option_names=["-h", "--help"]),
|
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
|
@click.pass_context
|
||||||
def maincli(ctx):
|
def maincli(ctx, filename, filter):
|
||||||
"""
|
"""
|
||||||
OpenvSwitch flow visualization utility.
|
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)
|
(such as the output of ovs-ofctl dump-flows or ovs-appctl dpctl/dump-flows)
|
||||||
and prints them in different formats.
|
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():
|
def main():
|
||||||
|
34
python/ovs/flowviz/odp/cli.py
Normal file
34
python/ovs/flowviz/odp/cli.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 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 JSONDatapathProcessor
|
||||||
|
|
||||||
|
|
||||||
|
@maincli.group(subcommand_metavar="FORMAT")
|
||||||
|
@click.pass_obj
|
||||||
|
def datapath(opts):
|
||||||
|
"""Process Datapath Flows."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@datapath.command()
|
||||||
|
@click.pass_obj
|
||||||
|
def json(opts):
|
||||||
|
"""Print the flows in JSON format."""
|
||||||
|
proc = JSONDatapathProcessor(opts)
|
||||||
|
proc.process()
|
||||||
|
print(proc.json_string())
|
34
python/ovs/flowviz/ofp/cli.py
Normal file
34
python/ovs/flowviz/ofp/cli.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 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 JSONOpenFlowProcessor
|
||||||
|
|
||||||
|
|
||||||
|
@maincli.group(subcommand_metavar="FORMAT")
|
||||||
|
@click.pass_obj
|
||||||
|
def openflow(opts):
|
||||||
|
"""Process OpenFlow Flows."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@openflow.command()
|
||||||
|
@click.pass_obj
|
||||||
|
def json(opts):
|
||||||
|
"""Print the flows in JSON format."""
|
||||||
|
proc = JSONOpenFlowProcessor(opts)
|
||||||
|
proc.process()
|
||||||
|
print(proc.json_string())
|
255
python/ovs/flowviz/process.py
Normal file
255
python/ovs/flowviz/process.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
# 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.start_thread() for each thread (Datapath flow only)
|
||||||
|
- 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_thread() after the thread block has been processed
|
||||||
|
(Datapath flow only)
|
||||||
|
- call self.stop_file() after the file has been processed entirely
|
||||||
|
|
||||||
|
In the case of stdin, the filename and file alias is 'stdin'.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
opts (dict): Options dictionary
|
||||||
|
flow_type (str): ["ofp", "odp"]
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, opts, flow_type):
|
||||||
|
self.opts = opts
|
||||||
|
assert flow_type in ["ofp", "odp"]
|
||||||
|
self.flow_type = flow_type
|
||||||
|
self.current_thread = None
|
||||||
|
|
||||||
|
# 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 start_thread(self, thread):
|
||||||
|
"""Called before the processing of a thread block begins.
|
||||||
|
Args:
|
||||||
|
thread(str): The thread name ("main" or "pmd at cpu core $N")
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def process_flow(self, flow, name):
|
||||||
|
"""Called for built flow (after filtering).
|
||||||
|
Args:
|
||||||
|
flow(Flow): The OpenFlow or Datapath flow.
|
||||||
|
name(str): The name of the file from which the flow comes
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def stop_thread(self, thread):
|
||||||
|
"""Called after the processing of a thread ends.
|
||||||
|
Args:
|
||||||
|
thread(str): The thread name ("main" or "pmd at cpu core $N")
|
||||||
|
"""
|
||||||
|
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_line(self, line, idx):
|
||||||
|
if self.flow_type == "odp":
|
||||||
|
next_thread = self.current_thread
|
||||||
|
if line.startswith("flow-dump from the main thread"):
|
||||||
|
next_thread = "main"
|
||||||
|
elif line.startswith("flow-dump from pmd on cpu core"):
|
||||||
|
next_thread = line.removeprefix("flow-dump from ").strip("\n")
|
||||||
|
|
||||||
|
if next_thread != self.current_thread:
|
||||||
|
if self.current_thread:
|
||||||
|
self.stop_thread(self.current_thread)
|
||||||
|
self.start_thread(next_thread)
|
||||||
|
self.current_thread = next_thread
|
||||||
|
return None
|
||||||
|
|
||||||
|
return ODPFlow(line, idx)
|
||||||
|
|
||||||
|
elif self.flow_type == "ofp":
|
||||||
|
# Skip strings commonly found in OpenFlow flow dumps.
|
||||||
|
if " reply " in line:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return OFPFlow(line, idx)
|
||||||
|
|
||||||
|
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.process_line(line, idx)
|
||||||
|
idx += 1
|
||||||
|
if not flow or (filt and not filt.evaluate(flow)):
|
||||||
|
continue
|
||||||
|
self.process_flow(flow, alias)
|
||||||
|
if self.current_thread:
|
||||||
|
self.stop_thread(self.current_thread)
|
||||||
|
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.process_line(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")
|
||||||
|
if self.current_thread:
|
||||||
|
self.stop_thread(self.current_thread)
|
||||||
|
self.stop_file("stdin", "stdin")
|
||||||
|
self.end()
|
||||||
|
|
||||||
|
|
||||||
|
class JSONOpenFlowProcessor(FileProcessor):
|
||||||
|
"""A FileProcessor that prints OpenFlow flows in JSON format."""
|
||||||
|
|
||||||
|
def __init__(self, opts):
|
||||||
|
super().__init__(opts, "ofp")
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JSONDatapathProcessor(FileProcessor):
|
||||||
|
"""A FileProcessor that prints Datapath flows in JSON format."""
|
||||||
|
|
||||||
|
def __init__(self, opts):
|
||||||
|
super().__init__(opts, "odp")
|
||||||
|
self.data = {}
|
||||||
|
self.thread = None
|
||||||
|
self.file = None
|
||||||
|
|
||||||
|
def start_file(self, name, filename):
|
||||||
|
self.per_thread_flows = None
|
||||||
|
self.flows_list = []
|
||||||
|
|
||||||
|
def start_thread(self, name):
|
||||||
|
if not self.per_thread_flows:
|
||||||
|
self.per_thread_flows = {}
|
||||||
|
|
||||||
|
def stop_thread(self, name):
|
||||||
|
self.per_thread_flows[name] = self.flows_list
|
||||||
|
|
||||||
|
def stop_file(self, name, filename):
|
||||||
|
if self.per_thread_flows:
|
||||||
|
self.data[name] = self.per_thread_flows
|
||||||
|
else:
|
||||||
|
self.data[name] = self.flows_list
|
||||||
|
|
||||||
|
def process_flow(self, flow, name):
|
||||||
|
self.flows_list.append(flow)
|
||||||
|
|
||||||
|
def json_string(self):
|
||||||
|
opts = {
|
||||||
|
"indent": 4,
|
||||||
|
"cls": FlowEncoder,
|
||||||
|
}
|
||||||
|
|
||||||
|
def thread_data(data):
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return {
|
||||||
|
thread: [flow.dict() for flow in flows]
|
||||||
|
for thread, flows in data.items()
|
||||||
|
}
|
||||||
|
return [flow.dict() for flow in data]
|
||||||
|
|
||||||
|
if len(self.data.keys()) > 1:
|
||||||
|
jsondata = {}
|
||||||
|
for file, file_data in self.data.items():
|
||||||
|
jsondata[file] = thread_data(file_data)
|
||||||
|
return json.dumps(jsondata, **opts)
|
||||||
|
else:
|
||||||
|
return json.dumps(
|
||||||
|
thread_data(next(iter(self.data.values()))), **opts
|
||||||
|
)
|
Reference in New Issue
Block a user