Add a spice-direct tempest test.

This test works through the full spice-direct console flow:

 - create an instance
 - request a console token
 - turn that console token into connection details
 - connect with those details and ensure you get a SPICE protocol
   handshake back

Change-Id: I9c4d1f05622d9a26db9edd2119eb03fdde726630
diff --git a/releasenotes/notes/deprecate-spice-rdp-console-config-f2af173552axfb72.yaml b/releasenotes/notes/deprecate-spice-rdp-console-config-f2af173552axfb72.yaml
index 58b161f..313b276 100644
--- a/releasenotes/notes/deprecate-spice-rdp-console-config-f2af173552axfb72.yaml
+++ b/releasenotes/notes/deprecate-spice-rdp-console-config-f2af173552axfb72.yaml
@@ -1,6 +1,10 @@
 ---
 deprecations:
   - |
-    The config options ``CONF.compute.spice_console`` and ``CONF.compute.rdp_console``
-    are deprecated because test cases using them are removed.
-    We can add them back when adding the test cases again.
+    The config option ``CONF.compute.rdp_console``
+    is deprecated because test cases using it have been removed.
+    We can add it back when adding the test cases again.
+  - |
+    The config option ``CONF.compute.spice_console`` was previously listed as
+    deprecated, but is now back in active use to support the testing of SPICE consoles
+    in Nova.
diff --git a/tempest/api/compute/admin/test_spice.py b/tempest/api/compute/admin/test_spice.py
new file mode 100644
index 0000000..d56bb2f
--- /dev/null
+++ b/tempest/api/compute/admin/test_spice.py
@@ -0,0 +1,153 @@
+# 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 socket
+import struct
+import urllib.parse as urlparse
+
+from tempest.api.compute import base
+from tempest import config
+from tempest.lib import decorators
+
+CONF = config.CONF
+
+
+class SpiceDirectConsoleTestJSON(base.BaseV2ComputeAdminTest):
+    """Test the spice-direct console"""
+
+    create_default_network = True
+
+    min_microversion = '2.98'
+    max_microversion = 'latest'
+
+    # SPICE client protocol constants
+    magic = b'REDQ'
+    major = 2
+    minor = 2
+    main_channel = 1
+    common_caps = 11  # AuthSelection, AuthSpice, MiniHeader
+    channel_caps = 9  # SemiSeamlessMigrate, SeamlessMigrate
+
+    @classmethod
+    def skip_checks(cls):
+        super().skip_checks()
+        if not CONF.compute_feature_enabled.spice_console:
+            raise cls.skipException('SPICE console feature is disabled.')
+
+    def tearDown(self):
+        super().tearDown()
+        # NOTE(zhufl): Because server_check_teardown will raise Exception
+        # which will prevent other cleanup steps from being executed, so
+        # server_check_teardown should be called after super's tearDown.
+        self.server_check_teardown()
+
+    @classmethod
+    def setup_clients(cls):
+        super().setup_clients()
+        cls.client = cls.servers_client
+
+    @classmethod
+    def resource_setup(cls):
+        super().resource_setup()
+        cls.server = cls.create_test_server(wait_until="ACTIVE")
+
+    @decorators.idempotent_id('80f4460d-1a06-403c-9e93-cf434c70be05')
+    def test_spice_direct(self):
+        """Test accessing spice-direct console of server"""
+
+        # Request a spice-direct console and validate the result. Any user can
+        # do this.
+        body = self.servers_client.get_remote_console(
+            self.server['id'], console_type='spice-direct', protocol='spice')
+
+        console_url = body['remote_console']['url']
+        parts = urlparse.urlparse(console_url)
+        qparams = urlparse.parse_qs(parts.query)
+        self.assertIn('token', qparams)
+        self.assertNotEmpty(qparams['token'])
+        self.assertEqual(1, len(qparams['token']))
+
+        self.assertEqual('spice', body['remote_console']['protocol'])
+        self.assertEqual('spice-direct', body['remote_console']['type'])
+
+        # For reasons best know to the python developers, the qparams values
+        # are lists as documented at
+        # https://docs.python.org/3/library/urllib.parse.html
+        token = qparams['token'][0]
+
+        # Turn that console token into hypervisor connection details. Only
+        # admins can do this because its expected that the request is coming
+        # from a proxy and we don't want to expose intimate hypervisor details
+        # to all users.
+        body = self.admin_servers_client.get_console_auth_token_details(
+            token)
+
+        console = body['console']
+        self.assertEqual(self.server['id'], console['instance_uuid'])
+        self.assertIn('port', console)
+        self.assertIn('tls_port', console)
+        self.assertIsNone(console['internal_access_path'])
+
+        # Connect to the specified non-TLS port and verify we get back
+        # a SPICE protocol greeting
+        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        sock.connect((console['host'], console['port']))
+
+        # Send a client greeting
+        #
+        # ---- SpiceLinkMess ----
+        # 4s    UINT32 magic value, must be REDQ
+        # I     UINT32 major_version, must be 2
+        # I     UINT32 minor_version, must be 2
+        # I     UINT32 size number of bytes following this field to the end
+        #              of this message.
+        # I     UINT32 connection_id. In case of a new session (i.e., channel
+        #              type is SPICE_CHANNEL_MAIN) this field is set to zero,
+        #              and in response the server will allocate session id
+        #              and will send it via the SpiceLinkReply message. In
+        #              case of all other channel types, this field will be
+        #              equal to the allocated session id.
+        # B     UINT8  channel_type, we use main
+        # B     UINT8  channel_id to connect to
+        # I     UINT32 num_common_caps number of common client channel
+        #              capabilities words
+        # I     UINT32 num_channel_caps number of specific client channel
+        #              capabilities words
+        # I     UINT32 caps_offset location of the start of the capabilities
+        #              vector given by the bytes offset from the “size”
+        #              member (i.e., from the address of the “connection_id”
+        #              member).
+        # ...          capabilities
+        sock.sendall(struct.pack(
+            '<4sIIIIBBIIIII', self.magic, self.major, self.minor, 42 - 16,
+            0, self.main_channel, 0, 1, 1, 18, self.common_caps,
+            self.channel_caps))
+
+        # ---- SpiceLinkReply ----
+        # 4s     UINT32 magic value, must be equal to SPICE_MAGIC
+        # I      UINT32 major_version, must be equal to SPICE_VERSION_MAJOR
+        # I      UINT32 minor_version, must be equal to SPICE_VERSION_MINOR
+        # I      UINT32 size number of bytes following this field to the end
+        #               of this message.
+        # I      UINT32 error code
+        # ...
+        buffered = sock.recv(20)
+        self.assertIsNotNone(buffered)
+        self.assertEqual(20, len(buffered))
+
+        magic, major, minor, _, error = struct.unpack_from('<4sIIII', buffered)
+        self.assertEqual(b'REDQ', magic)
+        self.assertEqual(2, major)
+        self.assertEqual(2, minor)
+        self.assertEqual(0, error)
diff --git a/tempest/config.py b/tempest/config.py
index 7719720..f9a08ea 100644
--- a/tempest/config.py
+++ b/tempest/config.py
@@ -540,12 +540,8 @@
                      'be same as nova.conf: vnc.enabled'),
     cfg.BoolOpt('spice_console',
                 default=False,
-                help='Enable Spice console. This configuration value should '
-                     'be same as nova.conf: spice.enabled',
-                deprecated_for_removal=True,
-                deprecated_reason="This config option is not being used "
-                                  "in Tempest, we can add it back when "
-                                  "adding the test cases."),
+                help='Enable SPICE console. This configuration value should '
+                     'be same as nova.conf: spice.enabled'),
     cfg.BoolOpt('serial_console',
                 default=False,
                 help='Enable serial console. This configuration value '
diff --git a/tempest/lib/api_schema/response/compute/v2_6/servers.py b/tempest/lib/api_schema/response/compute/v2_6/servers.py
index e6b2c32..05ab616 100644
--- a/tempest/lib/api_schema/response/compute/v2_6/servers.py
+++ b/tempest/lib/api_schema/response/compute/v2_6/servers.py
@@ -46,7 +46,8 @@
                 'properties': {
                     'protocol': {'enum': ['vnc', 'rdp', 'serial', 'spice']},
                     'type': {'enum': ['novnc', 'xvpvnc', 'rdp-html5',
-                                      'spice-html5', 'serial']},
+                                      'spice-html5',
+                                      'serial']},
                     'url': {
                         'type': 'string',
                         'format': 'uri'
diff --git a/tempest/lib/api_schema/response/compute/v2_98/__init__.py b/tempest/lib/api_schema/response/compute/v2_98/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tempest/lib/api_schema/response/compute/v2_98/__init__.py
diff --git a/tempest/lib/api_schema/response/compute/v2_98/servers.py b/tempest/lib/api_schema/response/compute/v2_98/servers.py
new file mode 100644
index 0000000..828dda1
--- /dev/null
+++ b/tempest/lib/api_schema/response/compute/v2_98/servers.py
@@ -0,0 +1,78 @@
+#    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 copy
+
+from tempest.lib.api_schema.response.compute.v2_96 import servers
+
+# NOTE: Below are the unchanged schema in this microversion. We need
+# to keep this schema in this file to have the generic way to select the
+# right schema based on self.schema_versions_info mapping in service client.
+# ****** Schemas unchanged since microversion 2.96 ******
+list_servers = copy.deepcopy(servers.list_servers)
+get_server = copy.deepcopy(servers.get_server)
+list_servers_detail = copy.deepcopy(servers.list_servers_detail)
+update_server = copy.deepcopy(servers.update_server)
+rebuild_server = copy.deepcopy(servers.rebuild_server)
+rebuild_server_with_admin_pass = copy.deepcopy(
+    servers.rebuild_server_with_admin_pass)
+show_server_diagnostics = copy.deepcopy(servers.show_server_diagnostics)
+attach_volume = copy.deepcopy(servers.attach_volume)
+show_volume_attachment = copy.deepcopy(servers.show_volume_attachment)
+list_volume_attachments = copy.deepcopy(servers.list_volume_attachments)
+show_instance_action = copy.deepcopy(servers.show_instance_action)
+create_backup = copy.deepcopy(servers.create_backup)
+
+console_auth_tokens = {
+    'status_code': [200],
+    'response_body': {
+        'type': 'object',
+        'properties': {
+            'console': {
+                'type': 'object',
+                'properties': {
+                    'instance_uuid': {'type': 'string'},
+                    'host': {'type': 'string'},
+                    'port': {'type': 'integer'},
+                    'tls_port': {'type': ['integer', 'null']},
+                    'internal_access_path': {'type': ['string', 'null']}
+                }
+            }
+        }
+    }
+}
+
+get_remote_consoles = {
+    'status_code': [200],
+    'response_body': {
+        'type': 'object',
+        'properties': {
+            'remote_console': {
+                'type': 'object',
+                'properties': {
+                    'protocol': {'enum': ['vnc', 'rdp', 'serial', 'spice']},
+                    'type': {'enum': ['novnc', 'xvpvnc', 'rdp-html5',
+                                      'spice-html5', 'spice-direct',
+                                      'serial']},
+                    'url': {
+                        'type': 'string',
+                        'format': 'uri'
+                    }
+                },
+                'additionalProperties': False,
+                'required': ['protocol', 'type', 'url']
+            }
+        },
+        'additionalProperties': False,
+        'required': ['remote_console']
+    }
+}
diff --git a/tempest/lib/services/compute/servers_client.py b/tempest/lib/services/compute/servers_client.py
index e91c87a..03562aa 100644
--- a/tempest/lib/services/compute/servers_client.py
+++ b/tempest/lib/services/compute/servers_client.py
@@ -46,6 +46,7 @@
 from tempest.lib.api_schema.response.compute.v2_89 import servers as schemav289
 from tempest.lib.api_schema.response.compute.v2_9 import servers as schemav29
 from tempest.lib.api_schema.response.compute.v2_96 import servers as schemav296
+from tempest.lib.api_schema.response.compute.v2_98 import servers as schemav298
 from tempest.lib.common import rest_client
 from tempest.lib.services.compute import base_compute_client
 
@@ -77,7 +78,9 @@
         {'min': '2.75', 'max': '2.78', 'schema': schemav275},
         {'min': '2.79', 'max': '2.88', 'schema': schemav279},
         {'min': '2.89', 'max': '2.95', 'schema': schemav289},
-        {'min': '2.96', 'max': None, 'schema': schemav296}]
+        {'min': '2.96', 'max': '2.97', 'schema': schemav296},
+        {'min': '2.98', 'max': None, 'schema': schemav298},
+    ]
 
     def __init__(self, auth_provider, service, region,
                  enable_instance_password=True, **kwargs):
@@ -680,6 +683,19 @@
         self.validate_response(schema.get_remote_consoles, resp, body)
         return rest_client.ResponseBody(resp, body)
 
+    def get_console_auth_token_details(self, token):
+        """Turn a console auth token into hypervisor connection details.
+
+        For a full list of available parameters, please refer to the official
+        API reference:
+        https://docs.openstack.org/api-ref/compute/#show-console-connection-information
+        """
+        resp, body = self.get('/os-console-auth-tokens/%s' % token)
+        body = json.loads(body)
+        schema = self.get_schema(self.schema_versions_info)
+        self.validate_response(schema.console_auth_tokens, resp, body)
+        return rest_client.ResponseBody(resp, body)
+
     def rescue_server(self, server_id, **kwargs):
         """Rescue the provided server.