libvirt: allow direct SPICE connections to qemu

This patch adds a new console type, "spice-direct", which provides
the connection information required to talk the native SPICE protocol
directly to qemu on the hypervisor. This is intended to be fronted
by a proxy which will handle authentication separately.

A new microversion is introduced which adds the type "spice-direct"
to the existing "spice" protocol.

An example request:

POST /servers/<uuid>/remote-consoles
{
  "remote_console": {
    "protocol": "spice",
    "type": "spice-direct"
  }
}

An example response:

{
  "remote_console": {
    "protocol": "spice",
    "type": "spice-direct",
    "url": "http://localhost:13200/nova?token=XXX";
  }
}

This token can then be used to lookup connection details for the
console using a request like this:

GET /os-console-auth-tokens/<consoletoken>

Which returns something like this:

{
  "console": {
    "instance_uuid": <uuid>,
    "host": <hypervisor>,
    "port": <a TCP port number>,
    "tls_port": <another TCP port number>,
    "internal_access_path": null
  }
}

APIImpact

Change-Id: I1e701cbabc0e2c435685e31465159eec09e3b1a0
This commit is contained in:
Michael Still
2025-02-22 08:25:38 +11:00
parent d8e95078cd
commit cbc263f6bc
25 changed files with 348 additions and 38 deletions

View File

@ -170,7 +170,7 @@
tox_envlist: all
# bug #1940425 only affect ml2/ovn so we execute
# test_live_migration_with_trunk in this job to keep
tempest_test_regex: (^tempest\..*compute\..*(migration|resize|reboot).*)
tempest_test_regex: (^tempest\..*compute\..*(migration|resize|reboot|spice).*)
devstack_localrc:
Q_AGENT: openvswitch
Q_ML2_TENANT_NETWORK_TYPE: vxlan

View File

@ -5993,8 +5993,9 @@ remote_console_protocol:
remote_console_type:
description: |
The type of remote console. The valid values are ``novnc``,
``spice-html5``, ``serial``, and ``webmks``. The type
``webmks`` is added since Microversion ``2.8``.
``spice-html5``, ``spice-direct``, ``serial``, and ``webmks``. The type
``webmks`` was added in Microversion ``2.8``, and the type
``spice-direct`` was added in Microversion ``2.99``.
in: body
required: true
type: string
@ -7102,6 +7103,12 @@ tenant_usages:
in: body
required: true
type: array
tls_port_number:
description: |
The port number of a port requiring a TLS connection.
in: body
required: false
type: integer
to_port:
description: |
The port at end of range.

View File

@ -40,6 +40,13 @@ Request
.. literalinclude:: ../../doc/api_samples/os-remote-consoles/v2.6/create-vnc-console-req.json
:language: javascript
**Example Get Remote spice-direct Console**
*``spice-direct`` consoles were added in microversion 2.99.*
.. literalinclude:: ../../doc/api_samples/os-remote-consoles/v2.99/create-spice-direct-console-req.json
:language: javascript
Response
--------
@ -55,6 +62,12 @@ Response
.. literalinclude:: ../../doc/api_samples/os-remote-consoles/v2.6/create-vnc-console-resp.json
:language: javascript
**Example Get Remote spice-direct Console**
*``spice-direct`` consoles were added in microversion 2.99.*
.. literalinclude:: ../../doc/api_samples/os-remote-consoles/v2.99/create-spice-direct-console-resp.json
:language: javascript
Show Console Connection Information
===================================
@ -90,9 +103,17 @@ Response
- instance_uuid: instance_id_body
- host: console_host
- port: port_number
- tls_port: tls_port_number
- internal_access_path: internal_access_path
**Example Show Console Authentication Token**
.. literalinclude:: ../../doc/api_samples/os-console-auth-tokens/v2.31/get-console-connect-info-get-resp.json
:language: javascript
**Example Console Connection Information for a spice-direct Console**
*``spice-direct`` consoles were added in microversion 2.99.*
.. literalinclude:: ../../doc/api_samples/os-console-auth-tokens/v2.99/get-console-connect-info-get-resp.json
:language: javascript

View File

@ -0,0 +1,6 @@
{
"remote_console": {
"protocol": "spice",
"type": "spice-direct"
}
}

View File

@ -0,0 +1,9 @@
{
"console": {
"host": "fakespiceconsole.com",
"instance_uuid": "16802173-4e67-44f9-ba84-6d99080b81b5",
"internal_access_path": null,
"port": 6969,
"tls_port": 6970
}
}

View File

@ -0,0 +1,6 @@
{
"remote_console": {
"protocol": "spice",
"type": "spice-direct"
}
}

View File

@ -0,0 +1,8 @@
{
"remote_console": {
"protocol": "spice",
"type": "spice-direct",
"url": "http://127.0.0.1:13002/nova?token=aeabd4ec-3acb-4898-9130-10521ccbe5f3"
}
}

View File

@ -19,8 +19,8 @@
}
],
"status": "CURRENT",
"version": "2.98",
"version": "2.99",
"min_version": "2.1",
"updated": "2013-07-23T11:33:21Z"
}
}
}

View File

@ -22,9 +22,9 @@
}
],
"status": "CURRENT",
"version": "2.98",
"version": "2.99",
"min_version": "2.1",
"updated": "2013-07-23T11:33:21Z"
}
]
}
}

View File

@ -268,6 +268,7 @@ REST_API_VERSION_HISTORY = """REST API Version History:
* 2.98 - Add support for returning embedded image properties in
``server show`` and ``server list --long`` and in the ``server
rebuild`` responses.
* 2.99 - Add the spice-direct console type to the spice console protocol.
"""
# The minimum and maximum versions of the API supported
@ -276,7 +277,7 @@ REST_API_VERSION_HISTORY = """REST API Version History:
# Note(cyeoh): This only applies for the v2.1 API once microversions
# support is fully merged. It does not affect the V2 API.
_MIN_API_VERSION = '2.1'
_MAX_API_VERSION = '2.98'
_MAX_API_VERSION = '2.99'
DEFAULT_API_VERSION = _MIN_API_VERSION
# Almost all proxy APIs which are related to network, images and baremetal

View File

@ -29,7 +29,7 @@ CONF = nova.conf.CONF
class ConsoleAuthTokensController(wsgi.Controller):
def _show(self, req, id):
def _show(self, req, id, include_tls_port=False):
"""Checks a console auth token and returns the related connect info."""
context = req.environ['nova.context']
context.can(cat_policies.BASE_POLICY_NAME)
@ -57,12 +57,19 @@ class ConsoleAuthTokensController(wsgi.Controller):
if not connect_info:
raise webob.exc.HTTPNotFound(explanation=_("Token not found"))
return {'console': {
'instance_uuid': connect_info.instance_uuid,
'host': connect_info.host,
'port': connect_info.port,
'internal_access_path': connect_info.internal_access_path,
}}
retval = {
'console': {
'instance_uuid': connect_info.instance_uuid,
'host': connect_info.host,
'port': connect_info.port,
'internal_access_path': connect_info.internal_access_path,
}
}
if connect_info.console_type == 'spice-direct' and include_tls_port:
retval['console']['tls_port'] = connect_info.tls_port
return retval
@wsgi.Controller.api_version("2.1", "2.30")
@wsgi.expected_errors((400, 401, 404))
@ -83,8 +90,14 @@ class ConsoleAuthTokensController(wsgi.Controller):
raise webob.exc.HTTPBadRequest()
@wsgi.Controller.api_version("2.31") # noqa
@wsgi.Controller.api_version("2.31", "2.98") # noqa
@wsgi.expected_errors((400, 404))
@validation.query_schema(schema.show_query)
@validation.response_body_schema(schema.show_response)
def show(self, req, id): # noqa
return self._show(req, id)
return self._show(req, id, include_tls_port=False)
@wsgi.Controller.api_version("2.99") # noqa
@wsgi.expected_errors((400, 404))
@validation.query_schema(schema.show_query)
def show(self, req, id): # noqa
return self._show(req, id, include_tls_port=True)

View File

@ -143,7 +143,8 @@ class RemoteConsolesController(wsgi.Controller):
@wsgi.Controller.api_version("2.6")
@wsgi.expected_errors((400, 404, 409, 501))
@validation.schema(schema.create_v26, "2.6", "2.7")
@validation.schema(schema.create_v28, "2.8")
@validation.schema(schema.create_v28, "2.8", "2.98")
@validation.schema(schema.create_v299, "2.99")
def create(self, req, server_id, body):
context = req.environ['nova.context']
instance = common.get_instance(self.compute_api, context, server_id)

View File

@ -1279,3 +1279,12 @@ under the struct at the existing ``image`` key in the response for
``GET /servers/{server_id}`` (server show), ``GET /servers/detail``
(list server --long) and in the rebuild case of
``POST /server/{server_id}/action`` (server rebuild) API response.
.. _microversion 2.99:
2.99
----
Add the ``spice-direct`` console type to the spice console protocol. Also
add a ``tls_port`` field to the return value from
``GET /os-console-auth-tokens/{console_token}``.

View File

@ -120,6 +120,30 @@ create_v28 = {
'additionalProperties': False,
}
create_v299 = {
'type': 'object',
'properties': {
'remote_console': {
'type': 'object',
'properties': {
'protocol': {
'type': 'string',
'enum': ['vnc', 'spice', 'rdp', 'serial', 'mks'],
},
'type': {
'type': 'string',
'enum': ['novnc', 'xvpvnc', 'spice-html5', 'spice-direct',
'serial', 'webmks'],
},
},
'required': ['protocol', 'type'],
'additionalProperties': False,
},
},
'required': ['remote_console'],
'additionalProperties': False,
}
get_vnc_console_response = {
'type': 'object',
'properties': {

View File

@ -7837,22 +7837,30 @@ class ComputeManager(manager.Manager):
if not CONF.spice.enabled:
raise exception.ConsoleTypeUnavailable(console_type=console_type)
if console_type != 'spice-html5':
if console_type not in ['spice-html5', 'spice-direct']:
raise exception.ConsoleTypeInvalid(console_type=console_type)
try:
# Retrieve connect info from driver, and then decorate with our
# access info token
console = self.driver.get_spice_console(context, instance)
console_auth = objects.ConsoleAuthToken(
context=context,
console_type=console_type,
host=console.host,
port=console.port,
internal_access_path=console.internal_access_path,
instance_uuid=instance.uuid,
access_url_base=CONF.spice.html5proxy_base_url,
)
fields = {
'context': context,
'console_type': console_type,
'host': console.host,
'port': console.port,
'tls_port': console.tlsPort,
'instance_uuid': instance.uuid
}
if console_type == 'spice-html5':
fields['internal_access_path'] = console.internal_access_path
fields['access_url_base'] = CONF.spice.html5proxy_base_url
if console_type == 'spice-direct':
fields['internal_access_path'] = None
fields['access_url_base'] = \
CONF.spice.spice_direct_proxy_base_url
console_auth = objects.ConsoleAuthToken(**fields)
console_auth.authorize(CONF.consoleauth.token_ttl)
connect_info = console.get_connection_info(
console_auth.token, console_auth.access_url)
@ -7974,7 +7982,7 @@ class ComputeManager(manager.Manager):
@wrap_exception()
@wrap_instance_fault
def validate_console_port(self, ctxt, instance, port, console_type):
if console_type == "spice-html5":
if console_type in ["spice-html5", "spice-direct"]:
console_info = self.driver.get_spice_console(ctxt, instance)
elif console_type == "serial":
console_info = self.driver.get_serial_console(ctxt, instance)

View File

@ -151,7 +151,7 @@ running. This service is typically launched on the controller node.
Possible values:
* Must be a valid URL of the form: ``http://host:port/spice_auto.html``
* Must be a valid URL of the form: ``http://host:port/spice_auto.html``
where host is the node running ``nova-spicehtml5proxy`` and the port is
typically 6082. Consider not using default value as it is not well defined
for any real deployment.
@ -161,6 +161,22 @@ Related options:
* This option depends on ``html5proxy_host`` and ``html5proxy_port`` options.
The access URL returned by the compute node must have the host
and port where the ``nova-spicehtml5proxy`` service is listening.
"""),
cfg.URIOpt('spice_direct_proxy_base_url',
default='http://127.0.0.1:13002/nova',
help="""
Location of a SPICE protocol native console proxy.
A user can retrieve a virt-viewer style .vv connection configuration file by
accessing this URL with the attached token when a console is created.
Possible values:
* Must be a valid URL of the form: ``http://host:port/nova`` where host is the
node running the SPICE protocol native proxy and the port is typically 13002.
Note that the port component is optional if you are using the default port
for HTTP or HTTPS. Consider not using the default value as it is not well
defined for any real deployment.
"""),
cfg.StrOpt('server_listen',
default='127.0.0.1',

View File

@ -0,0 +1,6 @@
{
"remote_console": {
"protocol": "spice",
"type": "spice-direct"
}
}

View File

@ -0,0 +1,9 @@
{
"console": {
"host": "%(host)s",
"instance_uuid": "%(id)s",
"internal_access_path": null,
"port": %(port)s,
"tls_port": %(tls_port)s
}
}

View File

@ -0,0 +1,7 @@
{
"remote_console": {
"protocol": "spice",
"type": "spice-direct"
}
}

View File

@ -0,0 +1,8 @@
{
"remote_console": {
"protocol": "spice",
"type": "spice-direct",
"url": "http://127.0.0.1:13002/nova?token=%(uuid)s"
}
}

View File

@ -52,3 +52,38 @@ class ConsoleAuthTokensSampleJsonTests(test_servers.ServersSampleBase):
subs["internal_access_path"] = ".*"
self._verify_response('get-console-connect-info-get-resp', subs,
response, 200)
class ConsoleV299AuthTokensSampleJsonTests(test_servers.ServersSampleBase):
ADMIN_API = True
sample_dir = "os-console-auth-tokens"
microversion = '2.99'
scenarios = [('v2_99', {'api_major_version': 'v2.1'})]
def _get_console_url(self, data):
return jsonutils.loads(data)["remote_console"]["url"]
def _get_console_token(self, uuid):
body = {'protocol': 'spice', 'type': 'spice-direct'}
response = self._do_post('servers/%s/remote-consoles' % uuid,
'create-spice-direct-console-req', body)
url = self._get_console_url(response.content)
return re.match('.+?token=([^&]+)', url).groups()[0]
def test_get_console_connect_info(self):
self.flags(enabled=True, group='spice')
uuid = self._post_server()
token = self._get_console_token(uuid)
response = self._do_get('os-console-auth-tokens/%s' % token)
subs = {}
subs["uuid"] = uuid
subs["host"] = r"[\w\.\-]+"
subs["port"] = "[0-9]+"
subs["tls_port"] = "[0-9]+"
subs["internal_access_path"] = ".*"
self._verify_response('get-console-connect-info-get-resp', subs,
response, 200)

View File

@ -125,3 +125,23 @@ class ConsolesV28SampleJsonTests(test_servers.ServersSampleBase):
'create-mks-console-req', body)
self._verify_response('create-mks-console-resp', {'url': HTTP_RE},
response, 200)
class ConsolesV299SampleJsonTests(test_servers.ServersSampleBase):
sample_dir = "os-remote-consoles"
microversion = '2.99'
scenarios = [('v2_99', {'api_major_version': 'v2.1'})]
def setUp(self):
super(ConsolesV299SampleJsonTests, self).setUp()
self.flags(enabled=True, group='spice')
def test_create_spice_direct_console(self):
uuid = self._post_server()
body = {'protocol': 'spice', 'type': 'spice-direct'}
response = self._do_post('servers/%s/remote-consoles' % uuid,
'create-spice-direct-console-req', body)
self._verify_response(
'create-spice-direct-console-resp', {'url': HTTP_RE},
response, 200)

View File

@ -20,7 +20,7 @@ import webob
from nova.api.openstack import api_version_request
from nova.api.openstack.compute import console_auth_tokens \
as console_auth_tokens_v21
as console_auth_tokens_v21
from nova import exception
from nova import objects
from nova import test
@ -30,17 +30,34 @@ from nova.tests.unit.api.openstack import fakes
class ConsoleAuthTokensExtensionTestV21(test.NoDBTestCase):
controller_class = console_auth_tokens_v21
_EXPECTED_OUTPUT = {'console': {'instance_uuid': fakes.FAKE_UUID,
'host': 'fake_host',
'port': '1234',
'internal_access_path': fakes.FAKE_UUID}}
_EXPECTED_OUTPUT = {
'console': {
'instance_uuid': fakes.FAKE_UUID,
'host': 'fake_host',
'port': '1234',
'internal_access_path': 'fake_access_path'
}
}
_EXPECTED_OUTPUT_SPICE = {
'console': {
'instance_uuid': fakes.FAKE_UUID,
'host': 'fake_host',
'port': '5900',
'tls_port': '5901',
'internal_access_path': None
}
}
# The database backend returns a ConsoleAuthToken.to_dict() and o.vo
# StringField are unicode. And the port is an IntegerField.
_EXPECTED_OUTPUT_DB = copy.deepcopy(_EXPECTED_OUTPUT)
_EXPECTED_OUTPUT_DB['console'].update(
{'host': 'fake_host', 'port': 1234,
'internal_access_path': fakes.FAKE_UUID})
'internal_access_path': 'fake_access_path'})
_EXPECTED_OUTPUT_DB_SPICE = copy.deepcopy(_EXPECTED_OUTPUT_SPICE)
_EXPECTED_OUTPUT_DB_SPICE['console'].update(
{'host': u'fake_host', 'port': 5900, 'tls_port': 5901})
def setUp(self):
super(ConsoleAuthTokensExtensionTestV21, self).setUp()
@ -63,9 +80,9 @@ class ConsoleAuthTokensExtensionTestV231(ConsoleAuthTokensExtensionTestV21):
'2.31')
@mock.patch('nova.objects.ConsoleAuthToken.validate',
return_value = objects.ConsoleAuthToken(
return_value=objects.ConsoleAuthToken(
instance_uuid=fakes.FAKE_UUID, host='fake_host',
port='1234', internal_access_path=fakes.FAKE_UUID,
port='1234', internal_access_path='fake_access_path',
console_type='webmks',
token=fakes.FAKE_UUID))
def test_get_console_connect_info(self, mock_validate):
@ -79,3 +96,38 @@ class ConsoleAuthTokensExtensionTestV231(ConsoleAuthTokensExtensionTestV21):
self.assertRaises(webob.exc.HTTPNotFound,
self.controller.show, self.req, fakes.FAKE_UUID)
mock_validate.assert_called_once_with(self.context, fakes.FAKE_UUID)
class ConsoleAuthTokensExtensionTestV299(ConsoleAuthTokensExtensionTestV21):
def setUp(self):
super(ConsoleAuthTokensExtensionTestV299, self).setUp()
self.req.api_version_request = api_version_request.APIVersionRequest(
'2.99')
@mock.patch('nova.objects.ConsoleAuthToken.validate',
return_value=objects.ConsoleAuthToken(
instance_uuid=fakes.FAKE_UUID, host='fake_host',
port='1234', internal_access_path='fake_access_path',
console_type='webmks', token=fakes.FAKE_UUID))
def test_get_console_connect_info(self, mock_validate):
output = self.controller.show(self.req, fakes.FAKE_UUID)
self.assertEqual(self._EXPECTED_OUTPUT_DB, output)
mock_validate.assert_called_once_with(self.context, fakes.FAKE_UUID)
@mock.patch('nova.objects.ConsoleAuthToken.validate',
return_value=objects.ConsoleAuthToken(
instance_uuid=fakes.FAKE_UUID, host='fake_host',
port='5900', tls_port='5901', internal_access_path=None,
console_type='spice-direct', token=fakes.FAKE_UUID))
def test_get_console_connect_info_spice(self, mock_validate):
output = self.controller.show(self.req, fakes.FAKE_UUID)
self.assertEqual(self._EXPECTED_OUTPUT_DB_SPICE, output)
mock_validate.assert_called_once_with(self.context, fakes.FAKE_UUID)
@mock.patch('nova.objects.ConsoleAuthToken.validate',
side_effect=exception.InvalidToken(token='***'))
def test_get_console_connect_info_token_not_found(self, mock_validate):
self.assertRaises(webob.exc.HTTPNotFound,
self.controller.show, self.req, fakes.FAKE_UUID)
mock_validate.assert_called_once_with(self.context, fakes.FAKE_UUID)

View File

@ -474,3 +474,35 @@ class ConsolesExtensionTestV28(ConsolesExtensionTestV26):
'url': 'http://fake'}}, output)
mock_handler.assert_called_once_with(self.context, self.instance,
'webmks')
class ConsolesExtensionTestV299(ConsolesExtensionTestV26):
def setUp(self):
super(ConsolesExtensionTestV299, self).setUp()
self.req = fakes.HTTPRequest.blank('')
self.context = self.req.environ['nova.context']
self.req.api_version_request = api_version_request.APIVersionRequest(
'2.99')
self.controller = console_v21.RemoteConsolesController()
def test_create_spice_direct_console(self):
mock_handler = mock.MagicMock()
mock_handler.return_value = {'url': 'http://fake'}
self.controller.handlers['spice'] = mock_handler
body = {
'remote_console': {
'protocol': 'spice',
'type': 'spice-direct'
}
}
output = self.controller.create(self.req, fakes.FAKE_UUID, body=body)
self.assertEqual({
'remote_console': {
'protocol': 'spice',
'type': 'spice-direct',
'url': 'http://fake'
}
}, output)
mock_handler.assert_called_once_with(self.context, self.instance,
'spice-direct')

View File

@ -6,3 +6,15 @@ features:
behavior, if set to true the SPICE consoles will require TLS
protected connections. Unencrypted connections will be gracefully
redirected to the TLS port via the SPICE protocol.
- |
This release adds a new console type, ``spice-direct`` which provides
the connection information required to talk the native SPICE
protocol directly to qemu on the hypervisor. This is intended to
be fronted by a proxy which will handle authentication separately.
This new console type is exposed in the Compute API v2.99
microversion. To facilitate this proxying, a new config option
``spice_direct_proxy_base_url`` is added to the spice configuration group.
This option is used to construct a URL containing an access token for
the console, and that access token can be turned into hypervisor
connection information using the pre-existing
os-console-auth-tokens API.