[SDFAB-502] Add P4Runtime CLI-based driver and UP4 library

- P4RuntimeCliDriver: A CLI driver based on the P4Runtime-shell. Requires to deactivate colors.
- UP4 library: suite of functions to attach/detach UEs using the P4RuntimeCliDriver
- Forward UP4 port when using k8s environment in the OnosClusterDriver when required
- Create the UP4 component (based on the P4RuntimeCliDriver) in the OnosClusterDriver when required

Change-Id: I96c683eec99f675b5a8f739fbdb84d7ce6869765
diff --git a/TestON/drivers/common/cli/onosclusterdriver.py b/TestON/drivers/common/cli/onosclusterdriver.py
index eebc5ad..8b25d22 100755
--- a/TestON/drivers/common/cli/onosclusterdriver.py
+++ b/TestON/drivers/common/cli/onosclusterdriver.py
@@ -54,7 +54,7 @@
         name. This method should return the (computed) attribute value
         or raise an AttributeError exception.
 
-        We will look into each of the node's component handles to try to find the attreibute, looking at REST first
+        We will look into each of the node's component handles to try to find the attribute, looking at REST first
         """
         if hasattr( self.REST, name ):
             main.log.debug( "%s: Using Rest driver's attribute for '%s'" % ( self.name, name ) )
@@ -65,10 +65,13 @@
         if hasattr( self.Bench, name ):
             main.log.debug( "%s: Using Bench driver's attribute for '%s'" % ( self.name, name ) )
             return getattr( self.Bench, name )
+        if hasattr( self.p4rtUp4, name ):
+            main.log.debug( "%s: Using UP4 driver's attribute for '%s'" % ( self.name, name ) )
+            return getattr( self.p4rtUp4, name )
         raise AttributeError( "Could not find the attribute %s in %r or it's component handles" % ( name, self ) )
 
     def __init__( self, name, ipAddress, CLI=None, REST=None, Bench=None, pos=None,
-                  userName=None, server=None, k8s=None, dockerPrompt=None ):
+                  userName=None, server=None, k8s=None, p4rtUp4=None, dockerPrompt=None ):
         # TODO: validate these arguments
         self.name = str( name )
         self.ipAddress = ipAddress
@@ -81,6 +84,7 @@
         self.user_name = userName
         self.server = server
         self.k8s = k8s
+        self.p4rtUp4 = p4rtUp4
         self.dockerPrompt = dockerPrompt
 
 class OnosClusterDriver( CLI ):
@@ -101,6 +105,7 @@
         self.nodeUser = None
         self.nodePass = None
         self.nodes = []
+        self.up4Port = None
         super( OnosClusterDriver, self ).__init__()
 
     def connect( self, **connectargs ):
@@ -145,6 +150,9 @@
                     self.maxNodes = self.options[ key ]
                 elif key == "kubeConfig":
                     self.kubeConfig = self.options[ key ]
+                elif key == "up4_port":
+                    # Defining up4_port triggers the creation of the P4RuntimeCliDriver component
+                    self.up4Port = self.options[ key ]
 
             self.home = self.checkOptions( self.home, "~/onos" )
             self.karafUser = self.checkOptions( self.karafUser, self.user_name )
@@ -160,6 +168,7 @@
             self.dockerPrompt = self.checkOptions( self.dockerPrompt, "~/onos#" )
             self.maxNodes = int( self.checkOptions( self.maxNodes, 100 ) )
             self.kubeConfig = self.checkOptions( self.kubeConfig, None )
+            self.up4Port = self.checkOptions(self.up4Port, None)
 
             self.name = self.options[ 'name' ]
 
@@ -243,14 +252,19 @@
                         # Setup port-forwarding and save the local port
                         guiPort = 8181
                         cliPort = 8101
+                        fwdPorts = [ guiPort, cliPort ]
+                        if self.up4Port:
+                            fwdPorts.append( int( self.up4Port ) )
                         portsList = ""
-                        for port in [ guiPort, cliPort ]:
+                        for port in fwdPorts:
                             localPort = port + index + 1
                             portsList += "%s:%s " % ( localPort, port )
                             if port == cliPort:
                                 node.CLI.karafPort = localPort
                             elif port == guiPort:
                                 node.REST.port = localPort
+                            elif self.up4Port and port == int( self.up4Port ):
+                                node.p4rtUp4.p4rtPort = localPort
                         main.log.info( "Setting up port forward for pod %s: [ %s ]" % ( self.podNames[ index ], portsList ) )
                         pf = kubectl.kubectlPortForward( self.podNames[ index ],
                                                          portsList,
@@ -466,6 +480,51 @@
             main.log.error( name + " component already exists!" )
             main.cleanAndExit()
 
+    def setP4rtCLIOptions( self, name, ipAddress ):
+        """
+        Parse the cluster options to create an UP4 component with the given name
+
+        Arguments:
+            name - The name of the P4RuntimeCLI component
+            ipAddress - The ip address of the ONOS instance
+        """
+        main.componentDictionary[name] = main.componentDictionary[self.name].copy()
+        main.componentDictionary[name]['type'] = "P4RuntimeCliDriver"
+        main.componentDictionary[name]['host'] = ipAddress
+        port = main.componentDictionary[name]['COMPONENTS'].get( "p4rt_port", "9559" )
+        main.componentDictionary[name]['p4rt_port'] = self.checkOptions( port, "9559" )
+        main.componentDictionary[name]['connect_order'] = str( int( main.componentDictionary[name]['connect_order'] ) + 1 )
+
+    def createP4rtCLIComponent( self, name, ipAddress ):
+        """
+        Creates a new P4Runtime CLI component. This will be connected to the node
+        ONOS is running on.
+
+        Arguments:
+            name - The string of the name of this component. The new component
+                   will be assigned to main.<name> .
+                   In addition, main.<name>.name = str( name )
+            ipAddress - The ip address of the server
+        """
+        try:
+            # look to see if this component already exists
+            getattr( main, name )
+        except AttributeError:
+            # namespace is clear, creating component
+            self.setP4rtCLIOptions( name, ipAddress )
+            return main.componentInit( name )
+        except pexpect.EOF:
+            main.log.error( self.name + ": EOF exception found" )
+            main.log.error( self.name + ":     " + self.handle.before )
+            main.cleanAndExit()
+        except Exception:
+            main.log.exception( self.name + ": Uncaught exception!" )
+            main.cleanAndExit()
+        else:
+            # namespace is not clear!
+            main.log.error( name + " component already exists!" )
+            main.cleanAndExit()
+
     def createComponents( self, prefix='', createServer=True ):
         """
         Creates a CLI and REST component for each nodes in the cluster
@@ -476,6 +535,7 @@
         benchPrefix = prefix + "bench"
         serverPrefix = prefix + "server"
         k8sPrefix = prefix + "k8s"
+        up4Prefix = prefix + "up4cl"
         for i in xrange( 1, self.maxNodes + 1 ):
             cliName = cliPrefix + str( i  )
             restName = restPrefix + str( i )
@@ -483,6 +543,8 @@
             serverName = serverPrefix + str( i )
             if self.kubeConfig:
                 k8sName = k8sPrefix + str( i )
+            if self.up4Port:
+                up4Name = up4Prefix + str( i )
 
             # Unfortunately this means we need to have a cell set beofre running TestON,
             # Even if it is just the entire possible cluster size
@@ -493,9 +555,10 @@
             bench = self.createBenchComponent( benchName )
             server = self.createServerComponent( serverName, ip ) if createServer else None
             k8s = self.createServerComponent( k8sName, ip ) if self.kubeConfig else None
+            p4rtUp4 = self.createP4rtCLIComponent( up4Name, ip ) if self.up4Port else None
             if self.kubeConfig:
                 k8s.kubeConfig = self.kubeConfig
                 k8s.podName = None
             self.nodes.append( Controller( prefix + str( i ), ip, cli, rest, bench, i - 1,
                                            self.user_name, server=server, k8s=k8s,
-                                           dockerPrompt=self.dockerPrompt ) )
+                                           p4rtUp4=p4rtUp4, dockerPrompt=self.dockerPrompt ) )
diff --git a/TestON/drivers/common/cli/p4runtimeclidriver.py b/TestON/drivers/common/cli/p4runtimeclidriver.py
new file mode 100644
index 0000000..6728cba
--- /dev/null
+++ b/TestON/drivers/common/cli/p4runtimeclidriver.py
@@ -0,0 +1,361 @@
+"""
+Copyright 2021 Open Networking Foundation (ONF)
+
+Please refer questions to either the onos test mailing list at <onos-test@onosproject.org>,
+the System Testing Plans and Results wiki page at <https://wiki.onosproject.org/x/voMg>,
+or the System Testing Guide page at <https://wiki.onosproject.org/x/WYQg>
+
+"""
+
+import pexpect
+import os
+from drivers.common.clidriver import CLI
+
+
+class P4RuntimeCliDriver(CLI):
+    """
+    Implements a P4Runtime Client CLI-based driver to control devices that uses
+    the P4Runtime Protocol, using the P4Runtime shell CLI.
+
+    This driver requires that P4Runtime CLI is configured to not generated colored
+    text. To do so, add the following lines to a file in
+    ~/.ipython/profile_default/ipython_config.py:
+        c.InteractiveShell.color_info = False
+        c.InteractiveShell.colors = 'NoColor'
+        c.TerminalInteractiveShell.color_info = False
+        c.TerminalInteractiveShell.colors = 'NoColor'
+    """
+
+    def __init__(self):
+        """
+        Initialize client
+        """
+        super(P4RuntimeCliDriver, self).__init__()
+        self.name = None
+        self.handle = None
+        self.p4rtAddress = "localhost"
+        self.p4rtPort = "9559"  # Default P4RT server port
+        self.prompt = "\$"
+        self.p4rtShPrompt = ">>>"
+        self.p4rtDeviceId = "1"
+        self.p4rtElectionId = "0,100"  # (high,low)
+        self.p4rtConfig = None  # Can be used to pass a path to the P4Info and pipeline config
+
+    def connect(self, **connectargs):
+        """
+        Creates the ssh handle for the P4Runtime CLI
+        The ip_address would come from the topo file using the host tag, the
+        value can be an environment variable as well as a "localhost" to get
+        the ip address needed to ssh to the "bench"
+        """
+        try:
+            for key in connectargs:
+                vars(self)[key] = connectargs[key]
+            self.name = self.options.get("name", "")
+            self.p4rtAddress = self.options.get("p4rt_address", "localhost")
+            self.p4rtPort = self.options.get("p4rt_port", "9559")
+            self.p4rtShPrompt = self.options.get("p4rt_sh_prompt", ">>>")
+            self.p4rtDeviceId = self.options.get("p4rt_device_id", "1")
+            self.p4rtElectionId = self.options.get("p4rt_election_id", "0,100")
+            self.p4rtConfig = self.options.get("p4rt_config", None)
+            try:
+                if os.getenv(str(self.ip_address)) is not None:
+                    self.ip_address = os.getenv(str(self.ip_address))
+                else:
+                    main.log.info(self.name + ": ip set to " + self.ip_address)
+            except KeyError:
+                main.log.info(self.name + ": Invalid host name," +
+                              "defaulting to 'localhost' instead")
+                self.ip_address = 'localhost'
+            except Exception as inst:
+                main.log.error("Uncaught exception: " + str(inst))
+
+            self.handle = super(P4RuntimeCliDriver, self).connect(
+                user_name=self.user_name,
+                ip_address=self.ip_address,
+                port=None,
+                pwd=self.pwd)
+            if self.handle:
+                main.log.info("Connection successful to the host " +
+                              self.user_name +
+                              "@" +
+                              self.ip_address)
+                self.handle.sendline("")
+                self.handle.expect(self.prompt)
+                return main.TRUE
+            else:
+                main.log.error("Connection failed to " +
+                               self.user_name +
+                               "@" +
+                               self.ip_address)
+                return main.FALSE
+        except pexpect.EOF:
+            main.log.error(self.name + ": EOF exception found")
+            main.log.error(self.name + ":     " + self.handle.before)
+            main.cleanAndExit()
+        except Exception:
+            main.log.exception(self.name + ": Uncaught exception!")
+            main.cleanAndExit()
+
+    def startP4RtClient(self, pushConfig=False):
+        """
+        Start the P4Runtime shell CLI client
+
+        :param pushConfig: True if you want to push the pipeline config, False otherwise
+            requires the p4rt_config configuration parameter to be set
+        :return:
+        """
+        try:
+            main.log.debug(self.name + ": Starting P4Runtime Shell CLI")
+            grpcAddr = "%s:%s" % (self.p4rtAddress, self.p4rtPort)
+            startP4RtShLine = "python3 -m p4runtime_sh --grpc-addr " + grpcAddr + \
+                              " --device-id " + self.p4rtDeviceId + \
+                              " --election-id " + self.p4rtElectionId
+            if pushConfig:
+                if self.p4rtConfig:
+                    startP4RtShLine += " --config " + self.p4rtConfig
+                else:
+                    main.log.error(
+                        "You should provide a P4 Runtime config to push!")
+                    main.cleanAndExit()
+            response = self.__clearSendAndExpect(startP4RtShLine)
+            self.preDisconnect = self.stopP4RtClient
+        except pexpect.TIMEOUT:
+            main.log.exception(self.name + ": Command timed out")
+            return main.FALSE
+        except pexpect.EOF:
+            main.log.exception(self.name + ": connection closed.")
+            main.cleanAndExit()
+        except Exception:
+            main.log.exception(self.name + ": Uncaught exception!")
+            main.cleanAndExit()
+
+    def stopP4RtClient(self):
+        """
+        Exit the P4Runtime shell CLI
+        """
+        try:
+            main.log.debug(self.name + ": Stopping P4Runtime Shell CLI")
+            self.handle.sendline("exit")
+            self.handle.expect(self.prompt)
+            return main.TRUE
+        except pexpect.TIMEOUT:
+            main.log.exception(self.name + ": Command timed out")
+            return main.FALSE
+        except pexpect.EOF:
+            main.log.exception(self.name + ": connection closed.")
+            main.cleanAndExit()
+        except Exception:
+            main.log.exception(self.name + ": Uncaught exception!")
+            main.cleanAndExit()
+
+    def pushTableEntry(self, tableEntry=None, debug=True):
+        """
+        Push a table entry with either the given table entry or use the saved
+        table entry in the variable 'te'.
+
+        Example of a valid tableEntry string:
+        te = table_entry["FabricIngress.forwarding.routing_v4"](action="set_next_id_routing_v4"); te.action["next_id"] = "10"; te.match["ipv4_dst"] = "10.0.0.0" # nopep8
+
+        :param tableEntry: the string table entry, if None it uses the table
+            entry saved in the 'te' variable
+        :param debug: True to enable debug logging, False otherwise
+        :return: main.TRUE or main.FALSE on error
+        """
+        try:
+            main.log.debug(self.name + ": Pushing Table Entry")
+            if debug:
+                self.handle.sendline("te")
+                self.handle.expect(self.p4rtShPrompt)
+            pushCmd = ""
+            if tableEntry:
+                pushCmd = tableEntry + ";"
+            pushCmd += "te.insert()"
+            response = self.__clearSendAndExpect(pushCmd)
+            if "Traceback" in response or "Error" in response:
+                # TODO: other possibile errors?
+                # NameError...
+                main.log.error(
+                    self.name + ": Error in pushing table entry: " + response)
+                return main.FALSE
+            return main.TRUE
+        except pexpect.TIMEOUT:
+            main.log.exception(self.name + ": Command timed out")
+            return main.FALSE
+        except pexpect.EOF:
+            main.log.exception(self.name + ": connection closed.")
+            main.cleanAndExit()
+        except Exception:
+            main.log.exception(self.name + ": Uncaught exception!")
+            main.cleanAndExit()
+
+    def deleteTableEntry(self, tableEntry=None, debug=True):
+        """
+        Deletes a table entry with either the given table entry or use the saved
+        table entry in the variable 'te'.
+
+        Example of a valid tableEntry string:
+        te = table_entry["FabricIngress.forwarding.routing_v4"](action="set_next_id_routing_v4"); te.action["next_id"] = "10"; te.match["ipv4_dst"] = "10.0.0.0" # nopep8
+
+        :param tableEntry: the string table entry, if None it uses the table
+            entry saved in the 'te' variable
+        :param debug: True to enable debug logging, False otherwise
+        :return: main.TRUE or main.FALSE on error
+        """
+        try:
+            main.log.debug(self.name + ": Deleting Table Entry")
+            if debug:
+                self.__clearSendAndExpect("te")
+            pushCmd = ""
+            if tableEntry:
+                pushCmd = tableEntry + ";"
+            pushCmd += "te.delete()"
+            response = self.__clearSendAndExpect(pushCmd)
+            main.log.debug(
+                self.name + ": Delete table entry response: {}".format(
+                    response))
+            if "Traceback" in response or "Error" in response:
+                # TODO: other possibile errors?
+                # NameError...
+                main.log.error(
+                    self.name + ": Error in deleting table entry: " + response)
+                return main.FALSE
+            return main.TRUE
+        except pexpect.TIMEOUT:
+            main.log.exception(self.name + ": Command timed out")
+            return main.FALSE
+        except pexpect.EOF:
+            main.log.exception(self.name + ": connection closed.")
+            main.cleanAndExit()
+        except Exception:
+            main.log.exception(self.name + ": Uncaught exception!")
+            main.cleanAndExit()
+
+    def buildP4RtTableEntry(self, tableName, actionName, actionParams={},
+                            matchFields={}):
+        """
+        Build a Table Entry
+        :param tableName: The name of table
+        :param actionName: The name of the action
+        :param actionParams: A dictionary containing name and values for the action parameters
+        :param matchFields: A dictionary containing name and values for the match fields
+        :return: main.TRUE or main.FALSE on error
+        """
+        # TODO: improve error checking when creating the table entry, add
+        #  params, and match fields.
+        try:
+            main.log.debug(self.name + ": Building P4RT Table Entry")
+            cmd = 'te = table_entry["%s"](action="%s"); ' % (
+                tableName, actionName)
+
+            # Action Parameters
+            for name, value in actionParams.items():
+                cmd += 'te.action["%s"]="%s";' % (name, str(value))
+
+            # Match Fields
+            for name, value in matchFields.items():
+                cmd += 'te.match["%s"]="%s";' % (name, str(value))
+
+            response = self.__clearSendAndExpect(cmd)
+            if "Unknown action" in response:
+                main.log.error("Unknown action: " + response)
+                return main.FALSE
+            if "AttributeError" in response:
+                main.log.error("Wrong action: " + response)
+                return main.FALSE
+            if "Invalid value" in response:
+                main.log.error("Invalid action value: " + response)
+                return main.FALSE
+            if "Action parameter value must be a string" in response:
+                main.log.error(
+                    "Action parameter value must be a string: " + response)
+                return main.FALSE
+            if "table" in response and "does not exist" in response:
+                main.log.error("Unknown table: " + response)
+                return main.FALSE
+            if "not a valid match field name" in response:
+                main.log.error("Invalid match field name: " + response)
+                return main.FALSE
+            if "is not a valid" in response:
+                main.log.error("Invalid match field: " + response)
+                return main.FALSE
+            if "Traceback" in response:
+                main.log.error("Error in creating the table entry: " + response)
+                return main.FALSE
+            return main.TRUE
+        except pexpect.TIMEOUT:
+            main.log.exception(self.name + ": Command timed out")
+            return main.FALSE
+        except pexpect.EOF:
+            main.log.exception(self.name + ": connection closed.")
+            main.cleanAndExit()
+        except Exception:
+            main.log.exception(self.name + ": Uncaught exception!")
+            main.cleanAndExit()
+
+    def disconnect(self):
+        """
+        Called at the end of the test to stop the p4rt CLI component and
+        disconnect the handle.
+        """
+        response = main.TRUE
+        try:
+            if self.handle:
+                self.handle.sendline("")
+                i = self.handle.expect([self.p4rtShPrompt, pexpect.TIMEOUT],
+                                       timeout=2)
+                if i != 1:
+                    # If the p4rtShell is still connected make sure to
+                    # disconnect it before
+                    self.stopP4RtClient()
+                i = self.handle.expect([self.prompt, pexpect.TIMEOUT],
+                                       timeout=2)
+                if i == 1:
+                    main.log.warn(
+                        self.name + ": timeout when waiting for response")
+                    main.log.warn(
+                        self.name + ": response: " + str(self.handle.before))
+                self.handle.sendline("exit")
+                i = self.handle.expect(["closed", pexpect.TIMEOUT], timeout=2)
+                if i == 1:
+                    main.log.warn(
+                        self.name + ": timeout when waiting for response")
+                    main.log.warn(
+                        self.name + ": response: " + str(self.handle.before))
+            return main.TRUE
+        except TypeError:
+            main.log.exception(self.name + ": Object not as expected")
+            response = main.FALSE
+        except pexpect.EOF:
+            main.log.error(self.name + ": EOF exception found")
+            main.log.error(self.name + ":     " + self.handle.before)
+        except ValueError:
+            main.log.exception("Exception in disconnect of " + self.name)
+            response = main.TRUE
+        except Exception:
+            main.log.exception(self.name + ": Connection failed to the host")
+            response = main.FALSE
+        return response
+
+    def __clearSendAndExpect(self, cmd):
+        self.clearBuffer()
+        self.handle.sendline(cmd)
+        self.handle.expect(self.p4rtShPrompt)
+        return self.handle.before
+
+    def clearBuffer(self, debug=False):
+        """
+        Keep reading from buffer until it's empty
+        """
+        i = 0
+        response = ''
+        while True:
+            try:
+                i += 1
+                # clear buffer
+                if debug:
+                    main.log.warn("%s expect loop iteration" % i)
+                self.handle.expect(self.p4rtShPrompt, timeout=5)
+                response += self.cleanOutput(self.handle.before, debug)
+            except pexpect.TIMEOUT:
+                return response