tests: replace pycodestyle with black
[vpp.git] / test / vpp_pg_interface.py
index c9e4076..2682774 100644 (file)
@@ -1,8 +1,48 @@
 import os
+import socket
+from socket import inet_pton, inet_ntop
+import struct
 import time
-from logging import error
-from scapy.utils import wrpcap, rdpcap
+from traceback import format_exc, format_stack
+
+from config import config
+import scapy.compat
+from scapy.utils import wrpcap, rdpcap, PcapReader
+from scapy.plist import PacketList
 from vpp_interface import VppInterface
+from vpp_papi import VppEnum
+
+from scapy.layers.l2 import Ether, ARP
+from scapy.layers.inet6 import (
+    IPv6,
+    ICMPv6ND_NS,
+    ICMPv6ND_NA,
+    ICMPv6NDOptSrcLLAddr,
+    ICMPv6NDOptDstLLAddr,
+    ICMPv6ND_RA,
+    RouterAlert,
+    IPv6ExtHdrHopByHop,
+)
+from util import ppp, ppc, UnexpectedPacketError
+from scapy.utils6 import in6_getnsma, in6_getnsmac, in6_ismaddr
+
+
+class CaptureTimeoutError(Exception):
+    """Exception raised if capture or packet doesn't appear within timeout"""
+
+    pass
+
+
+def is_ipv6_misc(p):
+    """Is packet one of uninteresting IPv6 broadcasts?"""
+    if p.haslayer(ICMPv6ND_RA):
+        if in6_ismaddr(p[IPv6].dst):
+            return True
+    if p.haslayer(IPv6ExtHdrHopByHop):
+        for o in p[IPv6ExtHdrHopByHop].options:
+            if isinstance(o, RouterAlert):
+                return True
+    return False
 
 
 class VppPGInterface(VppInterface):
@@ -15,30 +55,59 @@ class VppPGInterface(VppInterface):
         """packet-generator interface index assigned by VPP"""
         return self._pg_index
 
+    @property
+    def gso_enabled(self):
+        """gso enabled on packet-generator interface"""
+        if self._gso_enabled == 0:
+            return "gso-disabled"
+        return "gso-enabled"
+
+    @property
+    def gso_size(self):
+        """gso size on packet-generator interface"""
+        return self._gso_size
+
+    @property
+    def coalesce_is_enabled(self):
+        """coalesce enabled on packet-generator interface"""
+        if self._coalesce_enabled == 0:
+            return "coalesce-disabled"
+        return "coalesce-enabled"
+
     @property
     def out_path(self):
         """pcap file path - captured packets"""
         return self._out_path
 
-    @property
-    def in_path(self):
-        """ pcap file path - injected packets"""
-        return self._in_path
+    def get_in_path(self, worker):
+        """pcap file path - injected packets"""
+        if worker is not None:
+            return "%s/pg%u_wrk%u_in.pcap" % (self.test.tempdir, self.pg_index, worker)
+        return "%s/pg%u_in.pcap" % (self.test.tempdir, self.pg_index)
 
     @property
     def capture_cli(self):
         """CLI string to start capture on this interface"""
         return self._capture_cli
 
-    @property
-    def cap_name(self):
-        """capture name for this interface"""
+    def get_cap_name(self, worker=None):
+        """return capture name for this interface and given worker"""
+        if worker is not None:
+            return self._cap_name + "-worker%d" % worker
         return self._cap_name
 
-    @property
-    def input_cli(self):
-        """CLI string to load the injected packets"""
-        return self._input_cli
+    def get_input_cli(self, nb_replays=None, worker=None):
+        """return CLI string to load the injected packets"""
+        input_cli = "packet-generator new pcap %s source pg%u name %s" % (
+            self.get_in_path(worker),
+            self.pg_index,
+            self.get_cap_name(worker),
+        )
+        if nb_replays is not None:
+            return "%s limit %d" % (input_cli, nb_replays)
+        if worker is not None:
+            return "%s worker %d" % (input_cli, worker)
+        return input_cli
 
     @property
     def in_history_counter(self):
@@ -54,79 +123,451 @@ class VppPGInterface(VppInterface):
         self._out_history_counter += 1
         return v
 
-    def post_init_setup(self):
-        """ Perform post-init setup for super class and add our own setup """
-        super(VppPGInterface, self).post_init_setup()
-        self._out_file = "pg%u_out.pcap" % self.sw_if_index
-        self._out_path = self.test.tempdir + "/" + self._out_file
-        self._in_file = "pg%u_in.pcap" % self.sw_if_index
-        self._in_path = self.test.tempdir + "/" + self._in_file
-        self._capture_cli = "packet-generator capture pg%u pcap %s" % (
-            self.pg_index, self.out_path)
-        self._cap_name = "pcap%u" % self.sw_if_index
-        self._input_cli = "packet-generator new pcap %s source pg%u name %s" % (
-            self.in_path, self.pg_index, self.cap_name)
+    def __init__(self, test, pg_index, gso, gso_size, mode):
+        """Create VPP packet-generator interface"""
+        super().__init__(test)
+
+        r = test.vapi.pg_create_interface_v2(pg_index, gso, gso_size, mode)
+        self.set_sw_if_index(r.sw_if_index)
 
-    def __init__(self, test, pg_index):
-        """ Create VPP packet-generator interface """
         self._in_history_counter = 0
         self._out_history_counter = 0
+        self._out_assert_counter = 0
         self._pg_index = pg_index
-        self._test = test
-        r = self.test.vapi.pg_create_interface(self.pg_index)
-        self._sw_if_index = r.sw_if_index
-        self.post_init_setup()
+        self._gso_enabled = gso
+        self._gso_size = gso_size
+        self._coalesce_enabled = 0
+        self._out_file = "pg%u_out.pcap" % self.pg_index
+        self._out_path = self.test.tempdir + "/" + self._out_file
+        self._capture_cli = "packet-generator capture pg%u pcap %s" % (
+            self.pg_index,
+            self.out_path,
+        )
+        self._cap_name = "pcap%u-sw_if_index-%s" % (self.pg_index, self.sw_if_index)
 
-    def enable_capture(self):
-        """ Enable capture on this packet-generator interface"""
+    def handle_old_pcap_file(self, path, counter):
+        filename = os.path.basename(path)
+
+        if not config.keep_pcaps:
+            try:
+                self.test.logger.debug(f"Removing {path}")
+                os.remove(path)
+            except OSError:
+                self.test.logger.debug(f"OSError: Could not remove {path}")
+            return
+
+        # keep
         try:
-            if os.path.isfile(self.out_path):
-                os.rename(self.out_path,
-                          "%s/history.[timestamp:%f].[%s-counter:%04d].%s" %
-                          (self.test.tempdir,
-                           time.time(),
-                           self.name,
-                           self.out_history_counter,
-                           self._out_file))
-        except:
-            pass
+
+            if os.path.isfile(path):
+                name = "%s/history.[timestamp:%f].[%s-counter:%04d].%s" % (
+                    self.test.tempdir,
+                    time.time(),
+                    self.name,
+                    counter,
+                    filename,
+                )
+                self.test.logger.debug("Renaming %s->%s" % (path, name))
+                os.rename(path, name)
+        except OSError:
+            self.test.logger.debug("OSError: Could not rename %s %s" % (path, filename))
+
+    def enable_capture(self):
+        """Enable capture on this packet-generator interface
+        of at most n packets.
+        If n < 0, this is no limit
+        """
+        # disable the capture to flush the capture
+        self.disable_capture()
+        self.handle_old_pcap_file(self.out_path, self.out_history_counter)
         # FIXME this should be an API, but no such exists atm
         self.test.vapi.cli(self.capture_cli)
+        self._pcap_reader = None
 
-    def add_stream(self, pkts):
+    def disable_capture(self):
+        self.test.vapi.cli("%s disable" % self.capture_cli)
+
+    def coalesce_enable(self):
+        """Enable packet coalesce on this packet-generator interface"""
+        self._coalesce_enabled = 1
+        self.test.vapi.pg_interface_enable_disable_coalesce(self.sw_if_index, 1)
+
+    def coalesce_disable(self):
+        """Disable packet coalesce on this packet-generator interface"""
+        self._coalesce_enabled = 0
+        self.test.vapi.pg_interface_enable_disable_coalesce(self.sw_if_index, 0)
+
+    def add_stream(self, pkts, nb_replays=None, worker=None):
         """
         Add a stream of packets to this packet-generator
 
         :param pkts: iterable packets
 
         """
+        wrpcap(self.get_in_path(worker), pkts)
+        self.test.register_pcap(self, worker)
+        # FIXME this should be an API, but no such exists atm
+        self.test.vapi.cli(self.get_input_cli(nb_replays, worker))
+
+    def generate_debug_aid(self, kind):
+        """Create a hardlink to the out file with a counter and a file
+        containing stack trace to ease debugging in case of multiple capture
+        files present."""
+        self.test.logger.debug("Generating debug aid for %s on %s" % (kind, self._name))
+        link_path, stack_path = [
+            "%s/debug_%s_%s_%s.%s"
+            % (self.test.tempdir, self._name, self._out_assert_counter, kind, suffix)
+            for suffix in ["pcap", "stack"]
+        ]
+        os.link(self.out_path, link_path)
+        with open(stack_path, "w") as f:
+            f.writelines(format_stack())
+        self._out_assert_counter += 1
+
+    def _get_capture(self, timeout, filter_out_fn=is_ipv6_misc):
+        """Helper method to get capture and filter it"""
         try:
-            if os.path.isfile(self.in_path):
-                os.rename(self.in_path,
-                          "%s/history.[timestamp:%f].[%s-counter:%04d].%s" %
-                          (self.test.tempdir,
-                           time.time(),
-                           self.name,
-                           self.in_history_counter,
-                           self._in_file))
+            if not self.wait_for_capture_file(timeout):
+                return None
+            output = rdpcap(self.out_path)
+            self.test.logger.debug("Capture has %s packets" % len(output.res))
         except:
-            pass
-        wrpcap(self.in_path, pkts)
-        # FIXME this should be an API, but no such exists atm
-        self.test.vapi.cli(self.input_cli)
-        self.test.pg_streams.append(self.cap_name)
-        self.test.vapi.cli("trace add pg-input %d" % len(pkts))
+            self.test.logger.debug(
+                "Exception in scapy.rdpcap (%s): %s" % (self.out_path, format_exc())
+            )
+            return None
+        before = len(output.res)
+        if filter_out_fn:
+            output.res = [p for p in output.res if not filter_out_fn(p)]
+        removed = before - len(output.res)
+        if removed:
+            self.test.logger.debug(
+                "Filtered out %s packets from capture (returning %s)"
+                % (removed, len(output.res))
+            )
+        return output
 
-    def get_capture(self):
-        """
-        Get captured packets
+    def get_capture(
+        self, expected_count=None, remark=None, timeout=1, filter_out_fn=is_ipv6_misc
+    ):
+        """Get captured packets
 
+        :param expected_count: expected number of packets to capture, if None,
+                               then self.test.packet_count_for_dst_pg_idx is
+                               used to lookup the expected count
+        :param remark: remark printed into debug logs
+        :param timeout: how long to wait for packets
+        :param filter_out_fn: filter applied to each packet, packets for which
+                              the filter returns True are removed from capture
         :returns: iterable packets
         """
+        remaining_time = timeout
+        capture = None
+        name = self.name if remark is None else "%s (%s)" % (self.name, remark)
+        based_on = "based on provided argument"
+        if expected_count is None:
+            expected_count = self.test.get_packet_count_for_if_idx(self.sw_if_index)
+            based_on = "based on stored packet_infos"
+            if expected_count == 0:
+                raise Exception(
+                    "Internal error, expected packet count for %s is 0!" % name
+                )
+        self.test.logger.debug(
+            "Expecting to capture %s (%s) packets on %s"
+            % (expected_count, based_on, name)
+        )
+        while remaining_time > 0:
+            before = time.time()
+            capture = self._get_capture(remaining_time, filter_out_fn)
+            elapsed_time = time.time() - before
+            if capture:
+                if len(capture.res) == expected_count:
+                    # bingo, got the packets we expected
+                    return capture
+                elif len(capture.res) > expected_count:
+                    self.test.logger.error(ppc("Unexpected packets captured:", capture))
+                    break
+                else:
+                    self.test.logger.debug(
+                        "Partial capture containing %s "
+                        "packets doesn't match expected "
+                        "count %s (yet?)" % (len(capture.res), expected_count)
+                    )
+            elif expected_count == 0:
+                # bingo, got None as we expected - return empty capture
+                return PacketList()
+            remaining_time -= elapsed_time
+        if capture:
+            self.generate_debug_aid("count-mismatch")
+            if len(capture) > 0 and 0 == expected_count:
+                rem = f"\n{remark}" if remark else ""
+                raise UnexpectedPacketError(
+                    capture[0], f"\n({len(capture)} packets captured in total){rem}"
+                )
+            raise Exception(
+                "Captured packets mismatch, captured %s packets, "
+                "expected %s packets on %s" % (len(capture.res), expected_count, name)
+            )
+        else:
+            if 0 == expected_count:
+                return
+            raise Exception("No packets captured on %s" % name)
+
+    def assert_nothing_captured(
+        self, timeout=1, remark=None, filter_out_fn=is_ipv6_misc
+    ):
+        """Assert that nothing unfiltered was captured on interface
+
+        :param remark: remark printed into debug logs
+        :param filter_out_fn: filter applied to each packet, packets for which
+                              the filter returns True are removed from capture
+        """
+        capture = self.get_capture(
+            0, timeout=timeout, remark=remark, filter_out_fn=filter_out_fn
+        )
+        if not capture or len(capture.res) == 0:
+            # junk filtered out, we're good
+            return
+
+    def wait_for_pg_stop(self):
+        # wait till packet-generator is stopped
+        # "show packet-generator" while it is still running gives this:
+        # Name               Enabled        Count     Parameters
+        # pcap0-sw_if_inde     Yes           64       limit 64, ...
+        #
+        # also have a 5-minute timeout just in case things go terribly wrong...
+        deadline = time.time() + 300
+        while self.test.vapi.cli("show packet-generator").find("Yes") != -1:
+            self._test.sleep(0.01)  # yield
+            if time.time() > deadline:
+                self.test.logger.debug("Timeout waiting for pg to stop")
+                break
+
+    def wait_for_capture_file(self, timeout=1):
+        """
+        Wait until pcap capture file appears
+
+        :param timeout: How long to wait for the packet (default 1s)
+
+        :returns: True/False if the file is present or appears within timeout
+        """
+        self.wait_for_pg_stop()
+        deadline = time.time() + timeout
+        if not os.path.isfile(self.out_path):
+            self.test.logger.debug(
+                "Waiting for capture file %s to appear, "
+                "timeout is %ss" % (self.out_path, timeout)
+            )
+        else:
+            self.test.logger.debug("Capture file %s already exists" % self.out_path)
+            return True
+        while time.time() < deadline:
+            if os.path.isfile(self.out_path):
+                break
+            self._test.sleep(0)  # yield
+        if os.path.isfile(self.out_path):
+            self.test.logger.debug(
+                "Capture file appeared after %fs" % (time.time() - (deadline - timeout))
+            )
+        else:
+            self.test.logger.debug("Timeout - capture file still nowhere")
+            return False
+        return True
+
+    def verify_enough_packet_data_in_pcap(self):
+        """
+        Check if enough data is available in file handled by internal pcap
+        reader so that a whole packet can be read.
+
+        :returns: True if enough data present, else False
+        """
+        orig_pos = self._pcap_reader.f.tell()  # save file position
+        enough_data = False
+        # read packet header from pcap
+        packet_header_size = 16
+        caplen = None
+        end_pos = None
+        hdr = self._pcap_reader.f.read(packet_header_size)
+        if len(hdr) == packet_header_size:
+            # parse the capture length - caplen
+            sec, usec, caplen, wirelen = struct.unpack(
+                self._pcap_reader.endian + "IIII", hdr
+            )
+            self._pcap_reader.f.seek(0, 2)  # seek to end of file
+            end_pos = self._pcap_reader.f.tell()  # get position at end
+            if end_pos >= orig_pos + len(hdr) + caplen:
+                enough_data = True  # yay, we have enough data
+        self._pcap_reader.f.seek(orig_pos, 0)  # restore original position
+        return enough_data
+
+    def wait_for_packet(self, timeout, filter_out_fn=is_ipv6_misc):
+        """
+        Wait for next packet captured with a timeout
+
+        :param timeout: How long to wait for the packet
+
+        :returns: Captured packet if no packet arrived within timeout
+        :raises Exception: if no packet arrives within timeout
+        """
+        deadline = time.time() + timeout
+        if self._pcap_reader is None:
+            if not self.wait_for_capture_file(timeout):
+                raise CaptureTimeoutError(
+                    "Capture file %s did not appear within timeout" % self.out_path
+                )
+            while time.time() < deadline:
+                try:
+                    self._pcap_reader = PcapReader(self.out_path)
+                    break
+                except:
+                    self.test.logger.debug(
+                        "Exception in scapy.PcapReader(%s): %s"
+                        % (self.out_path, format_exc())
+                    )
+        if not self._pcap_reader:
+            raise CaptureTimeoutError(
+                "Capture file %s did not appear within timeout" % self.out_path
+            )
+
+        poll = False
+        if timeout > 0:
+            self.test.logger.debug("Waiting for packet")
+        else:
+            poll = True
+            self.test.logger.debug("Polling for packet")
+        while time.time() < deadline or poll:
+            if not self.verify_enough_packet_data_in_pcap():
+                self._test.sleep(0)  # yield
+                poll = False
+                continue
+            p = self._pcap_reader.recv()
+            if p is not None:
+                if filter_out_fn is not None and filter_out_fn(p):
+                    self.test.logger.debug(
+                        "Packet received after %ss was filtered out"
+                        % (time.time() - (deadline - timeout))
+                    )
+                else:
+                    self.test.logger.debug(
+                        "Packet received after %fs"
+                        % (time.time() - (deadline - timeout))
+                    )
+                    return p
+            self._test.sleep(0)  # yield
+            poll = False
+        self.test.logger.debug("Timeout - no packets received")
+        raise CaptureTimeoutError("Packet didn't arrive within timeout")
+
+    def create_arp_req(self):
+        """Create ARP request applicable for this interface"""
+        return Ether(dst="ff:ff:ff:ff:ff:ff", src=self.remote_mac) / ARP(
+            op=ARP.who_has,
+            pdst=self.local_ip4,
+            psrc=self.remote_ip4,
+            hwsrc=self.remote_mac,
+        )
+
+    def create_ndp_req(self, addr=None):
+        """Create NDP - NS applicable for this interface"""
+        if not addr:
+            addr = self.local_ip6
+        nsma = in6_getnsma(inet_pton(socket.AF_INET6, addr))
+        d = inet_ntop(socket.AF_INET6, nsma)
+
+        return (
+            Ether(dst=in6_getnsmac(nsma))
+            / IPv6(dst=d, src=self.remote_ip6)
+            / ICMPv6ND_NS(tgt=addr)
+            / ICMPv6NDOptSrcLLAddr(lladdr=self.remote_mac)
+        )
+
+    def resolve_arp(self, pg_interface=None):
+        """Resolve ARP using provided packet-generator interface
+
+        :param pg_interface: interface used to resolve, if None then this
+            interface is used
+
+        """
+        if pg_interface is None:
+            pg_interface = self
+        self.test.logger.info(
+            "Sending ARP request for %s on port %s"
+            % (self.local_ip4, pg_interface.name)
+        )
+        arp_req = self.create_arp_req()
+        pg_interface.add_stream(arp_req)
+        pg_interface.enable_capture()
+        self.test.pg_start()
+        self.test.logger.info(self.test.vapi.cli("show trace"))
         try:
-            output = rdpcap(self.out_path)
-        except IOError:  # TODO
-            error("File %s does not exist, probably because no"
-                  " packets arrived" % self.out_path)
-            return []
-        return output
+            captured_packet = pg_interface.wait_for_packet(1)
+        except:
+            self.test.logger.info("No ARP received on port %s" % pg_interface.name)
+            return
+        arp_reply = captured_packet.copy()  # keep original for exception
+        try:
+            if arp_reply[ARP].op == ARP.is_at:
+                self.test.logger.info(
+                    "VPP %s MAC address is %s " % (self.name, arp_reply[ARP].hwsrc)
+                )
+                self._local_mac = arp_reply[ARP].hwsrc
+            else:
+                self.test.logger.info("No ARP received on port %s" % pg_interface.name)
+        except:
+            self.test.logger.error(
+                ppp("Unexpected response to ARP request:", captured_packet)
+            )
+            raise
+
+    def resolve_ndp(self, pg_interface=None, timeout=1, link_layer=False):
+        """Resolve NDP using provided packet-generator interface
+
+        :param pg_interface: interface used to resolve, if None then this
+            interface is used
+        :param timeout: how long to wait for response before giving up
+        :param link_layer: resolve for global address if False (default)
+            or for link-layer address if True
+
+        """
+        if pg_interface is None:
+            pg_interface = self
+        addr = self.local_ip6_ll if link_layer else self.local_ip6
+        self.test.logger.info(
+            "Sending NDP request for %s on port %s" % (addr, pg_interface.name)
+        )
+        ndp_req = self.create_ndp_req(addr)
+        pg_interface.add_stream(ndp_req)
+        pg_interface.enable_capture()
+        self.test.pg_start()
+        now = time.time()
+        deadline = now + timeout
+        # Enabling IPv6 on an interface can generate more than the
+        # ND reply we are looking for (namely MLD). So loop through
+        # the replies to look for want we want.
+        while now < deadline:
+            try:
+                captured_packet = pg_interface.wait_for_packet(
+                    deadline - now, filter_out_fn=None
+                )
+            except:
+                self.test.logger.error("Timeout while waiting for NDP response")
+                raise
+            ndp_reply = captured_packet.copy()  # keep original for exception
+            try:
+                ndp_na = ndp_reply[ICMPv6ND_NA]
+                opt = ndp_na[ICMPv6NDOptDstLLAddr]
+                self.test.logger.info(
+                    "VPP %s MAC address is %s " % (self.name, opt.lladdr)
+                )
+                self._local_mac = opt.lladdr
+                self.test.logger.debug(self.test.vapi.cli("show trace"))
+                # we now have the MAC we've been after
+                return
+            except:
+                self.test.logger.info(
+                    ppp("Unexpected response to NDP request:", captured_packet)
+                )
+            now = time.time()
+
+        self.test.logger.debug(self.test.vapi.cli("show trace"))
+        raise Exception("Timeout while waiting for NDP response")