Merge "Improve security groups management."
diff --git a/.zuul.yaml b/.zuul.yaml
index bd8619f..7d7fa5e 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -16,7 +16,7 @@
       tox_envlist: all
       devstack_localrc:
         TEMPEST_PLUGINS: /opt/stack/neutron-tempest-plugin
-        NETWORK_API_EXTENSIONS: address-scope,agent,allowed-address-pairs,auto-allocated-topology,availability_zone,binding,default-subnetpools,dhcp_agent_scheduler,dns-domain-ports,dns-integration,dvr,empty-string-filtering,ext-gw-mode,external-net,extra_dhcp_opt,extraroute,filter-validation,fip-port-details,flavors,ip-substring-filtering,l3-flavors,l3-ha,l3_agent_scheduler,logging,metering,multi-provider,net-mtu,net-mtu-writable,network-ip-availability,network_availability_zone,pagination,port-security,project-id,provider,qos,qos-bw-minimum-ingress,qos-fip,quotas,quota_details,rbac-policies,router,router_availability_zone,security-group,port-mac-address-regenerate,port-security-groups-filtering,segment,service-type,sorting,standard-attr-description,standard-attr-revisions,standard-attr-segment,standard-attr-timestamp,standard-attr-tag,subnet_allocation,trunk,trunk-details
+        NETWORK_API_EXTENSIONS: address-scope,agent,allowed-address-pairs,auto-allocated-topology,availability_zone,binding,default-subnetpools,dhcp_agent_scheduler,dns-domain-ports,dns-integration,dvr,empty-string-filtering,ext-gw-mode,external-net,extra_dhcp_opt,extraroute,filter-validation,fip-port-details,flavors,ip-substring-filtering,l3-flavors,l3-ha,l3_agent_scheduler,logging,metering,multi-provider,net-mtu,net-mtu-writable,network-ip-availability,network_availability_zone,pagination,port-security,project-id,provider,qos,qos-bw-minimum-ingress,qos-fip,quotas,quota_details,rbac-policies,router,router_availability_zone,security-group,port-mac-address-regenerate,port-security-groups-filtering,segment,service-type,sorting,standard-attr-description,standard-attr-revisions,standard-attr-segment,standard-attr-timestamp,standard-attr-tag,subnet_allocation,trunk,trunk-details,uplink-status-propagation
       devstack_plugins:
         neutron: git://git.openstack.org/openstack/neutron.git
         neutron-tempest-plugin: git://git.openstack.org/openstack/neutron-tempest-plugin.git
@@ -27,6 +27,7 @@
         neutron-qos: true
         neutron-segments: true
         neutron-trunk: true
+        neutron-uplink-status-propagation: true
       devstack_local_conf:
         post-config:
           $NEUTRON_CONF:
diff --git a/neutron_tempest_plugin/api/base.py b/neutron_tempest_plugin/api/base.py
index bcafe03..0faf254 100644
--- a/neutron_tempest_plugin/api/base.py
+++ b/neutron_tempest_plugin/api/base.py
@@ -676,7 +676,8 @@
     @classmethod
     def delete_router(cls, router, client=None):
         client = client or cls.client
-        client.remove_router_extra_routes(router['id'])
+        if 'routes' in router:
+            client.remove_router_extra_routes(router['id'])
         body = client.list_router_interfaces(router['id'])
         interfaces = [port for port in body['ports']
                       if port['device_owner'] in const.ROUTER_INTERFACE_OWNERS]
diff --git a/neutron_tempest_plugin/api/test_ports.py b/neutron_tempest_plugin/api/test_ports.py
index 3b877c2..52783b9 100644
--- a/neutron_tempest_plugin/api/test_ports.py
+++ b/neutron_tempest_plugin/api/test_ports.py
@@ -134,6 +134,28 @@
         expected = [s['id'], s['id']]
         self.assertEqual(expected, subnets)
 
+    @decorators.idempotent_id('9700828d-86eb-4f21-9fa3-da487a2d77f2')
+    @utils.requires_ext(extension="uplink-status-propagation",
+                        service="network")
+    def test_create_port_with_propagate_uplink_status(self):
+        body = self.create_port(self.network, propagate_uplink_status=True)
+        self.assertTrue(body['propagate_uplink_status'])
+        body = self.client.list_ports(id=body['id'])['ports'][0]
+        self.assertTrue(body['propagate_uplink_status'])
+        body = self.client.show_port(body['id'])['port']
+        self.assertTrue(body['propagate_uplink_status'])
+
+    @decorators.idempotent_id('c396a880-0c7b-409d-a80b-800a3d09bdc4')
+    @utils.requires_ext(extension="uplink-status-propagation",
+                        service="network")
+    def test_create_port_without_propagate_uplink_status(self):
+        body = self.create_port(self.network)
+        self.assertFalse(body['propagate_uplink_status'])
+        body = self.client.list_ports(id=body['id'])['ports'][0]
+        self.assertFalse(body['propagate_uplink_status'])
+        body = self.client.show_port(body['id'])['port']
+        self.assertFalse(body['propagate_uplink_status'])
+
 
 class PortsSearchCriteriaTest(base.BaseSearchCriteriaTest):
 
diff --git a/neutron_tempest_plugin/common/shell.py b/neutron_tempest_plugin/common/shell.py
new file mode 100644
index 0000000..bd4a7a3
--- /dev/null
+++ b/neutron_tempest_plugin/common/shell.py
@@ -0,0 +1,180 @@
+# Copyright (c) 2018 Red Hat, Inc.
+#
+# All Rights Reserved.
+#
+#    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 collections
+import subprocess
+import sys
+
+from oslo_log import log
+from tempest.lib import exceptions as lib_exc
+
+from neutron_tempest_plugin.common import ssh
+from neutron_tempest_plugin import config
+from neutron_tempest_plugin import exceptions
+
+
+LOG = log.getLogger(__name__)
+
+CONF = config.CONF
+
+if ssh.Client.proxy_jump_host:
+    # Perform all SSH connections passing through configured SSH server
+    SSH_PROXY_CLIENT = ssh.Client.create_proxy_client()
+else:
+    SSH_PROXY_CLIENT = None
+
+
+def execute(command, ssh_client=None, timeout=None, check=True):
+    """Execute command inside a remote or local shell
+
+    :param command: command string to be executed
+
+    :param ssh_client: SSH client instance used for remote shell execution
+
+    :param timeout: command execution timeout in seconds
+
+    :param check: when False it doesn't raises ShellCommandError when
+    exit status is not zero. True by default
+
+    :returns: STDOUT text when command execution terminates with zero exit
+    status.
+
+    :raises ShellTimeoutExpired: when timeout expires before command execution
+    terminates. In such case it kills the process, then it eventually would
+    try to read STDOUT and STDERR buffers (not fully implemented) before
+    raising the exception.
+
+    :raises ShellCommandError: when command execution terminates with non-zero
+    exit status.
+    """
+    ssh_client = ssh_client or SSH_PROXY_CLIENT
+    if timeout:
+        timeout = float(timeout)
+
+    if ssh_client:
+        result = execute_remote_command(command=command, timeout=timeout,
+                                        ssh_client=ssh_client)
+    else:
+        result = execute_local_command(command=command, timeout=timeout)
+
+    if result.exit_status == 0:
+        LOG.debug("Command %r succeeded:\n"
+                  "stderr:\n%s\n"
+                  "stdout:\n%s\n",
+                  command, result.stderr, result.stdout)
+    elif result.exit_status is None:
+        LOG.debug("Command %r timeout expired (timeout=%s):\n"
+                  "stderr:\n%s\n"
+                  "stdout:\n%s\n",
+                  command, timeout, result.stderr, result.stdout)
+    else:
+        LOG.debug("Command %r failed (exit_status=%s):\n"
+                  "stderr:\n%s\n"
+                  "stdout:\n%s\n",
+                  command, result.exit_status, result.stderr, result.stdout)
+    if check:
+        result.check()
+
+    return result
+
+
+def execute_remote_command(command, ssh_client, timeout=None):
+    """Execute command on a remote host using SSH client"""
+    LOG.debug("Executing command %r on remote host %r (timeout=%r)...",
+              command, ssh_client.host, timeout)
+
+    stdout = stderr = exit_status = None
+
+    try:
+        # TODO(fressi): re-implement to capture stderr
+        stdout = ssh_client.exec_command(command, timeout=timeout)
+        exit_status = 0
+
+    except lib_exc.TimeoutException:
+        # TODO(fressi): re-implement to capture STDOUT and STDERR and make
+        # sure process is killed
+        pass
+
+    except lib_exc.SSHExecCommandFailed as ex:
+        # Please note class SSHExecCommandFailed has been re-based on
+        # top of ShellCommandError
+        stdout = ex.stdout
+        stderr = ex.stderr
+        exit_status = ex.exit_status
+
+    return ShellExecuteResult(command=command, timeout=timeout,
+                              exit_status=exit_status,
+                              stdout=stdout, stderr=stderr)
+
+
+def execute_local_command(command, timeout=None):
+    """Execute command on local host using local shell"""
+
+    LOG.debug("Executing command %r on local host (timeout=%r)...",
+              command, timeout)
+
+    process = subprocess.Popen(command, shell=True,
+                               universal_newlines=True,
+                               stdout=subprocess.PIPE,
+                               stderr=subprocess.PIPE)
+
+    if timeout and sys.version_info < (3, 3):
+        # TODO(fressi): re-implement to timeout support on older Pythons
+        LOG.warning("Popen.communicate method doens't support for timeout "
+                    "on Python %r", sys.version)
+        timeout = None
+
+    # Wait for process execution while reading STDERR and STDOUT streams
+    if timeout:
+        try:
+            stdout, stderr = process.communicate(timeout=timeout)
+        except subprocess.TimeoutExpired:
+            # At this state I expect the process to be still running
+            # therefore it has to be kill later after calling poll()
+            LOG.exception("Command %r timeout expired.", command)
+            stdout = stderr = None
+    else:
+        stdout, stderr = process.communicate()
+
+    # Check process termination status
+    exit_status = process.poll()
+    if exit_status is None:
+        # The process is still running after calling communicate():
+        # let kill it and then read buffers again
+        process.kill()
+        stdout, stderr = process.communicate()
+
+    return ShellExecuteResult(command=command, timeout=timeout,
+                              stdout=stdout, stderr=stderr,
+                              exit_status=exit_status)
+
+
+class ShellExecuteResult(collections.namedtuple(
+        'ShellExecuteResult', ['command', 'timeout', 'exit_status', 'stdout',
+                               'stderr'])):
+
+    def check(self):
+        if self.exit_status is None:
+            raise exceptions.ShellTimeoutExpired(command=self.command,
+                                                 timeout=self.timeout,
+                                                 stderr=self.stderr,
+                                                 stdout=self.stdout)
+
+        elif self.exit_status != 0:
+            raise exceptions.ShellCommandError(command=self.command,
+                                               exit_status=self.exit_status,
+                                               stderr=self.stderr,
+                                               stdout=self.stdout)
diff --git a/neutron_tempest_plugin/common/ssh.py b/neutron_tempest_plugin/common/ssh.py
index 33dffcb..ea30a28 100644
--- a/neutron_tempest_plugin/common/ssh.py
+++ b/neutron_tempest_plugin/common/ssh.py
@@ -43,13 +43,13 @@
 
     def __init__(self, host, username, password=None, timeout=None, pkey=None,
                  channel_timeout=10, look_for_keys=False, key_filename=None,
-                 port=22, proxy_client=None):
+                 port=22, proxy_client=None, create_proxy_client=True):
 
         timeout = timeout or self.timeout
 
-        if self.proxy_jump_host:
+        if not proxy_client and create_proxy_client and self.proxy_jump_host:
             # Perform all SSH connections passing through configured SSH server
-            proxy_client = proxy_client or self.create_proxy_client(
+            proxy_client = self.create_proxy_client(
                 timeout=timeout, channel_timeout=channel_timeout)
 
         super(Client, self).__init__(
@@ -115,10 +115,10 @@
                         "set 'proxy_jump_keyfile' to provide a valid SSH key "
                         "file.", login)
 
-        return ssh.Client(
+        return Client(
             host=host, username=username, password=password,
             look_for_keys=look_for_keys, key_filename=key_file,
-            port=port, proxy_client=None, **kwargs)
+            port=port, create_proxy_client=False, **kwargs)
 
     # attribute used to keep reference to opened client connection
     _client = None
@@ -179,6 +179,16 @@
                                         user=self.username,
                                         password=self.password)
 
+    def exec_command(self, cmd, encoding="utf-8", timeout=None):
+        if timeout:
+            original_timeout = self.timeout
+            self.timeout = timeout
+        try:
+            return super(Client, self).exec_command(cmd=cmd, encoding=encoding)
+        finally:
+            if timeout:
+                self.timeout = original_timeout
+
     def execute_script(self, script, become_root=False, combine_stderr=False,
                        shell='sh -eux', timeout=None, **params):
         """Connect to remote machine and executes script.
@@ -285,12 +295,12 @@
         stderr = _buffer_to_string(error_data, encoding)
         if exit_status is None:
             raise exc.SSHScriptTimeoutExpired(
-                host=self.host, script=script, stderr=stderr, stdout=stdout,
-                timeout=timeout)
+                command=shell, host=self.host, script=script, stderr=stderr,
+                stdout=stdout, timeout=timeout)
         else:
             raise exc.SSHScriptFailed(
-                host=self.host, script=script, stderr=stderr, stdout=stdout,
-                exit_status=exit_status)
+                command=shell, host=self.host, script=script, stderr=stderr,
+                stdout=stdout, exit_status=exit_status)
 
 
 def _buffer_to_string(data_buffer, encoding):
diff --git a/neutron_tempest_plugin/common/utils.py b/neutron_tempest_plugin/common/utils.py
index fa7bb8b..3649cb6 100644
--- a/neutron_tempest_plugin/common/utils.py
+++ b/neutron_tempest_plugin/common/utils.py
@@ -88,3 +88,17 @@
                 raise self.skipTest(msg)
         return inner
     return decor
+
+
+def override_class(overriden_class, overrider_class):
+    """Override class definition with a MixIn class
+
+    If overriden_class is not a subclass of overrider_class then it creates
+    a new class that has as bases overrider_class and overriden_class.
+    """
+
+    if not issubclass(overriden_class, overrider_class):
+        name = overriden_class.__name__
+        bases = (overrider_class, overriden_class)
+        overriden_class = type(name, bases, {})
+    return overriden_class
diff --git a/neutron_tempest_plugin/exceptions.py b/neutron_tempest_plugin/exceptions.py
index ff5b2cf..895cb40 100644
--- a/neutron_tempest_plugin/exceptions.py
+++ b/neutron_tempest_plugin/exceptions.py
@@ -15,18 +15,35 @@
 
 from tempest.lib import exceptions
 
-TempestException = exceptions.TempestException
+from neutron_tempest_plugin.common import utils
 
 
-class InvalidConfiguration(TempestException):
+class NeutronTempestPluginException(exceptions.TempestException):
+
+    def __init__(self, **kwargs):
+        super(NeutronTempestPluginException, self).__init__(**kwargs)
+        self._properties = kwargs
+
+    def __getattr__(self, name):
+        try:
+            return self._properties[name]
+        except KeyError:
+            pass
+
+        msg = ("AttributeError: {!r} object has no attribute {!r}").format(
+            self, name)
+        raise AttributeError(msg)
+
+
+class InvalidConfiguration(NeutronTempestPluginException):
     message = "Invalid Configuration"
 
 
-class InvalidCredentials(TempestException):
+class InvalidCredentials(NeutronTempestPluginException):
     message = "Invalid Credentials"
 
 
-class InvalidServiceTag(TempestException):
+class InvalidServiceTag(NeutronTempestPluginException):
     message = "Invalid service tag"
 
 
@@ -34,17 +51,50 @@
     """Base class for SSH client execute_script() exceptions"""
 
 
-class SSHScriptTimeoutExpired(SSHScriptException):
-    message = ("Timeout expired while executing script on host %(host)r:\n"
-               "script:\n%(script)s\n"
-               "stderr:\n%(stderr)s\n"
-               "stdout:\n%(stdout)s\n"
-               "timeout: %(timeout)s")
+class ShellError(NeutronTempestPluginException):
+    pass
 
 
-class SSHScriptFailed(SSHScriptException):
-    message = ("Failed executing script on remote host %(host)r:\n"
+class ShellCommandFailed(ShellError):
+    """Raised when shell command exited with non-zero status
+
+    """
+    message = ("Command %(command)r failed, exit status: %(exit_status)d, "
+               "stderr:\n%(stderr)s\n"
+               "stdout:\n%(stdout)s")
+
+
+class SSHScriptFailed(ShellCommandFailed):
+    message = ("Command %(command)r failed, exit status: %(exit_status)d, "
+               "host: %(host)r\n"
                "script:\n%(script)s\n"
                "stderr:\n%(stderr)s\n"
-               "stdout:\n%(stdout)s\n"
-               "exit_status: %(exit_status)s")
+               "stdout:\n%(stdout)s")
+
+
+class ShellTimeoutExpired(ShellError):
+    """Raised when shell command timeouts and has been killed before exiting
+
+    """
+    message = ("Command '%(command)s' timed out: %(timeout)d, "
+               "stderr:\n%(stderr)s\n"
+               "stdout:\n%(stdout)s")
+
+
+class SSHScriptTimeoutExpired(ShellTimeoutExpired):
+    message = ("Command '%(command)s', timed out: %(timeout)d "
+               "host: %(host)r\n"
+               "script:\n%(script)s\n"
+               "stderr:\n%(stderr)s\n"
+               "stdout:\n%(stdout)s")
+
+
+# Patch SSHExecCommandFailed exception to make sure we can access to fields
+# command, exit_status, STDOUT and STDERR when SSH client reports command
+# failure
+exceptions.SSHExecCommandFailed = utils.override_class(
+    exceptions.SSHExecCommandFailed, ShellCommandFailed)
+
+# Above code created a new SSHExecCommandFailed class based on top
+# of ShellCommandError
+assert issubclass(exceptions.SSHExecCommandFailed, ShellCommandFailed)