diff --git a/doc/api_samples/os-consoles/get-spice-console-post-req.json b/doc/api_samples/os-consoles/get-spice-console-post-req.json new file mode 100644 index 000000000000..d04f7c7ae9fe --- /dev/null +++ b/doc/api_samples/os-consoles/get-spice-console-post-req.json @@ -0,0 +1,5 @@ +{ + "os-getSPICEConsole": { + "type": "spice-html5" + } +} diff --git a/doc/api_samples/os-consoles/get-spice-console-post-req.xml b/doc/api_samples/os-consoles/get-spice-console-post-req.xml new file mode 100644 index 000000000000..59052abea273 --- /dev/null +++ b/doc/api_samples/os-consoles/get-spice-console-post-req.xml @@ -0,0 +1,2 @@ + + diff --git a/doc/api_samples/os-consoles/get-spice-console-post-resp.json b/doc/api_samples/os-consoles/get-spice-console-post-resp.json new file mode 100644 index 000000000000..f4999e1babcf --- /dev/null +++ b/doc/api_samples/os-consoles/get-spice-console-post-resp.json @@ -0,0 +1,6 @@ +{ + "console": { + "type": "spice-html5", + "url": "http://example.com:6080/spice_auto.html?token=f9906a48-b71e-4f18-baca-c987da3ebdb3&title=dafa(75ecef58-3b8e-4659-ab3b-5501454188e9)" + } +} diff --git a/doc/api_samples/os-consoles/get-spice-console-post-resp.xml b/doc/api_samples/os-consoles/get-spice-console-post-resp.xml new file mode 100644 index 000000000000..acba8b1f03a0 --- /dev/null +++ b/doc/api_samples/os-consoles/get-spice-console-post-resp.xml @@ -0,0 +1,5 @@ + + + spice-html5 + http://example.com:6080/spice_auto.html?token=f9906a48-b71e-4f18-baca-c987da3ebdb3 + diff --git a/nova/api/openstack/compute/contrib/consoles.py b/nova/api/openstack/compute/contrib/consoles.py index 4f88d033c145..4895a9e7b27a 100644 --- a/nova/api/openstack/compute/contrib/consoles.py +++ b/nova/api/openstack/compute/contrib/consoles.py @@ -53,10 +53,33 @@ class ConsolesController(wsgi.Controller): return {'console': {'type': console_type, 'url': output['url']}} + @wsgi.action('os-getSPICEConsole') + def get_spice_console(self, req, id, body): + """Get text console output.""" + context = req.environ['nova.context'] + authorize(context) + + # If type is not supplied or unknown, get_spice_console below will cope + console_type = body['os-getSPICEConsole'].get('type') + + try: + instance = self.compute_api.get(context, id) + output = self.compute_api.get_spice_console(context, + instance, + console_type) + except exception.InstanceNotFound as e: + raise webob.exc.HTTPNotFound(explanation=unicode(e)) + except exception.InstanceNotReady as e: + raise webob.exc.HTTPConflict(explanation=unicode(e)) + + return {'console': {'type': console_type, 'url': output['url']}} + def get_actions(self): """Return the actions the extension adds, as required by contract.""" actions = [extensions.ActionExtension("servers", "os-getVNCConsole", - self.get_vnc_console)] + self.get_vnc_console), + extensions.ActionExtension("servers", "os-getSPICEConsole", + self.get_spice_console)] return actions diff --git a/nova/compute/api.py b/nova/compute/api.py index 8ba6b97aa07d..c7ca0640d144 100644 --- a/nova/compute/api.py +++ b/nova/compute/api.py @@ -2030,6 +2030,29 @@ class API(base.Base): instance=instance, console_type=console_type) return connect_info + @wrap_check_policy + def get_spice_console(self, context, instance, console_type): + """Get a url to an instance Console.""" + if not instance['host']: + raise exception.InstanceNotReady(instance_id=instance['uuid']) + + connect_info = self.compute_rpcapi.get_spice_console(context, + instance=instance, console_type=console_type) + + self.consoleauth_rpcapi.authorize_console(context, + connect_info['token'], console_type, connect_info['host'], + connect_info['port'], connect_info['internal_access_path']) + + return {'url': connect_info['access_url']} + + def get_spice_connect_info(self, context, instance, console_type): + """Used in a child cell to get console info.""" + if not instance['host']: + raise exception.InstanceNotReady(instance_id=instance['uuid']) + connect_info = self.compute_rpcapi.get_spice_console(context, + instance=instance, console_type=console_type) + return connect_info + @wrap_check_policy def get_console_output(self, context, instance, tail_length=None): """Get console output for an instance.""" diff --git a/nova/compute/cells_api.py b/nova/compute/cells_api.py index d1d9a11d20ae..d5427a04b027 100644 --- a/nova/compute/cells_api.py +++ b/nova/compute/cells_api.py @@ -439,6 +439,21 @@ class ComputeCellsAPI(compute_api.API): connect_info['port'], connect_info['internal_access_path']) return {'url': connect_info['access_url']} + @wrap_check_policy + @validate_cell + def get_spice_console(self, context, instance, console_type): + """Get a url to a SPICE Console.""" + if not instance['host']: + raise exception.InstanceNotReady(instance_id=instance['uuid']) + + connect_info = self._call_to_cells(context, instance, + 'get_spice_connect_info', console_type) + + self.consoleauth_rpcapi.authorize_console(context, + connect_info['token'], console_type, connect_info['host'], + connect_info['port'], connect_info['internal_access_path']) + return {'url': connect_info['access_url']} + @validate_cell def get_console_output(self, context, instance, *args, **kwargs): """Get console output for an an instance.""" diff --git a/nova/compute/manager.py b/nova/compute/manager.py index 3bf8e61efaae..0136f765997c 100644 --- a/nova/compute/manager.py +++ b/nova/compute/manager.py @@ -293,7 +293,7 @@ class ComputeVirtAPI(virtapi.VirtAPI): class ComputeManager(manager.SchedulerDependentManager): """Manages the running instances from creation to destruction.""" - RPC_API_VERSION = '2.23' + RPC_API_VERSION = '2.24' def __init__(self, compute_driver=None, *args, **kwargs): """Load configuration options and connect to the hypervisor.""" @@ -2387,6 +2387,9 @@ class ComputeManager(manager.SchedulerDependentManager): LOG.debug(_("Getting vnc console"), instance=instance) token = str(uuid.uuid4()) + if not CONF.vnc_enabled: + raise exception.ConsoleTypeInvalid(console_type=console_type) + if console_type == 'novnc': # For essex, novncproxy_base_url must include the full path # including the html file (like http://myhost/vnc_auto.html) @@ -2404,6 +2407,33 @@ class ComputeManager(manager.SchedulerDependentManager): return connect_info + @exception.wrap_exception(notifier=notifier, publisher_id=publisher_id()) + @wrap_instance_fault + def get_spice_console(self, context, console_type, instance): + """Return connection information for a spice console.""" + context = context.elevated() + LOG.debug(_("Getting spice console"), instance=instance) + token = str(uuid.uuid4()) + + if not CONF.spice.enabled: + raise exception.ConsoleTypeInvalid(console_type=console_type) + + if console_type == 'spice-html5': + # For essex, spicehtml5proxy_base_url must include the full path + # including the html file (like http://myhost/spice_auto.html) + access_url = '%s?token=%s' % (CONF.spice.html5proxy_base_url, + token) + else: + raise exception.ConsoleTypeInvalid(console_type=console_type) + + # Retrieve connect info from driver, and then decorate with our + # access info token + connect_info = self.driver.get_spice_console(instance) + connect_info['token'] = token + connect_info['access_url'] = access_url + + return connect_info + def _attach_volume_boot(self, context, instance, volume, mountpoint): """Attach a volume to an instance at boot time. So actual attach is done by instance creation""" diff --git a/nova/compute/rpcapi.py b/nova/compute/rpcapi.py index 3e7ed1cfd143..525d1adc741b 100644 --- a/nova/compute/rpcapi.py +++ b/nova/compute/rpcapi.py @@ -158,6 +158,7 @@ class ComputeAPI(nova.openstack.common.rpc.proxy.RpcProxy): 2.22 - Add recreate, on_shared_storage and host arguments to rebuild_instance() 2.23 - Remove network_info from reboot_instance + 2.24 - Added get_spice_console method ''' # @@ -295,6 +296,13 @@ class ComputeAPI(nova.openstack.common.rpc.proxy.RpcProxy): instance=instance_p, console_type=console_type), topic=_compute_topic(self.topic, ctxt, None, instance)) + def get_spice_console(self, ctxt, instance, console_type): + instance_p = jsonutils.to_primitive(instance) + return self.call(ctxt, self.make_msg('get_spice_console', + instance=instance_p, console_type=console_type), + topic=_compute_topic(self.topic, ctxt, None, instance), + version='2.24') + def host_maintenance_mode(self, ctxt, host_param, mode, host): '''Set host maintenance mode diff --git a/nova/tests/api/openstack/compute/contrib/test_consoles.py b/nova/tests/api/openstack/compute/contrib/test_consoles.py index d251c6b75735..cf044dfcd9ab 100644 --- a/nova/tests/api/openstack/compute/contrib/test_consoles.py +++ b/nova/tests/api/openstack/compute/contrib/test_consoles.py @@ -26,19 +26,36 @@ def fake_get_vnc_console(self, _context, _instance, _console_type): return {'url': 'http://fake'} +def fake_get_spice_console(self, _context, _instance, _console_type): + return {'url': 'http://fake'} + + def fake_get_vnc_console_invalid_type(self, _context, _instance, _console_type): raise exception.ConsoleTypeInvalid(console_type=_console_type) +def fake_get_spice_console_invalid_type(self, _context, + _instance, _console_type): + raise exception.ConsoleTypeInvalid(console_type=_console_type) + + def fake_get_vnc_console_not_ready(self, _context, instance, _console_type): raise exception.InstanceNotReady(instance_id=instance["uuid"]) +def fake_get_spice_console_not_ready(self, _context, instance, _console_type): + raise exception.InstanceNotReady(instance_id=instance["uuid"]) + + def fake_get_vnc_console_not_found(self, _context, instance, _console_type): raise exception.InstanceNotFound(instance_id=instance["uuid"]) +def fake_get_spice_console_not_found(self, _context, instance, _console_type): + raise exception.InstanceNotFound(instance_id=instance["uuid"]) + + def fake_get(self, context, instance_uuid): return {'uuid': instance_uuid} @@ -53,6 +70,8 @@ class ConsolesExtensionTest(test.TestCase): super(ConsolesExtensionTest, self).setUp() self.stubs.Set(compute_api.API, 'get_vnc_console', fake_get_vnc_console) + self.stubs.Set(compute_api.API, 'get_spice_console', + fake_get_spice_console) self.stubs.Set(compute_api.API, 'get', fake_get) self.flags( osapi_compute_extension=[ @@ -132,3 +151,76 @@ class ConsolesExtensionTest(test.TestCase): res = req.get_response(self.app) self.assertEqual(res.status_int, 400) + + def test_get_spice_console(self): + body = {'os-getSPICEConsole': {'type': 'spice-html5'}} + req = webob.Request.blank('/v2/fake/servers/1/action') + req.method = "POST" + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + res = req.get_response(self.app) + output = jsonutils.loads(res.body) + self.assertEqual(res.status_int, 200) + self.assertEqual(output, + {u'console': {u'url': u'http://fake', u'type': u'spice-html5'}}) + + def test_get_spice_console_not_ready(self): + self.stubs.Set(compute_api.API, 'get_spice_console', + fake_get_spice_console_not_ready) + body = {'os-getSPICEConsole': {'type': 'spice-html5'}} + req = webob.Request.blank('/v2/fake/servers/1/action') + req.method = "POST" + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + res = req.get_response(self.app) + output = jsonutils.loads(res.body) + self.assertEqual(res.status_int, 409) + + def test_get_spice_console_no_type(self): + self.stubs.Set(compute_api.API, 'get_spice_console', + fake_get_spice_console_invalid_type) + body = {'os-getSPICEConsole': {}} + req = webob.Request.blank('/v2/fake/servers/1/action') + req.method = "POST" + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + res = req.get_response(self.app) + self.assertEqual(res.status_int, 400) + + def test_get_spice_console_no_instance(self): + self.stubs.Set(compute_api.API, 'get', fake_get_not_found) + body = {'os-getSPICEConsole': {'type': 'spice-html5'}} + req = webob.Request.blank('/v2/fake/servers/1/action') + req.method = "POST" + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + res = req.get_response(self.app) + self.assertEqual(res.status_int, 404) + + def test_get_spice_console_no_instance_on_console_get(self): + self.stubs.Set(compute_api.API, 'get_spice_console', + fake_get_spice_console_not_found) + body = {'os-getSPICEConsole': {'type': 'spice-html5'}} + req = webob.Request.blank('/v2/fake/servers/1/action') + req.method = "POST" + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + res = req.get_response(self.app) + self.assertEqual(res.status_int, 404) + + def test_get_spice_console_invalid_type(self): + body = {'os-getSPICEConsole': {'type': 'invalid'}} + self.stubs.Set(compute_api.API, 'get_spice_console', + fake_get_spice_console_invalid_type) + req = webob.Request.blank('/v2/fake/servers/1/action') + req.method = "POST" + req.body = jsonutils.dumps(body) + req.headers["content-type"] = "application/json" + + res = req.get_response(self.app) + self.assertEqual(res.status_int, 400) diff --git a/nova/tests/compute/test_compute.py b/nova/tests/compute/test_compute.py index 0d9f67231893..3740d598e727 100644 --- a/nova/tests/compute/test_compute.py +++ b/nova/tests/compute/test_compute.py @@ -1335,6 +1335,9 @@ class ComputeTestCase(BaseTestCase): def test_novnc_vnc_console(self): # Make sure we can a vnc console for an instance. + self.flags(vnc_enabled=True) + self.flags(enabled=False, group='spice') + instance = jsonutils.to_primitive(self._create_fake_instance()) self.compute.run_instance(self.context, instance=instance) @@ -1347,6 +1350,9 @@ class ComputeTestCase(BaseTestCase): def test_xvpvnc_vnc_console(self): # Make sure we can a vnc console for an instance. + self.flags(vnc_enabled=True) + self.flags(enabled=False, group='spice') + instance = jsonutils.to_primitive(self._create_fake_instance()) self.compute.run_instance(self.context, instance=instance) @@ -1357,6 +1363,9 @@ class ComputeTestCase(BaseTestCase): def test_invalid_vnc_console_type(self): # Raise useful error if console type is an unrecognised string. + self.flags(vnc_enabled=True) + self.flags(enabled=False, group='spice') + instance = jsonutils.to_primitive(self._create_fake_instance()) self.compute.run_instance(self.context, instance=instance) @@ -1367,6 +1376,9 @@ class ComputeTestCase(BaseTestCase): def test_missing_vnc_console_type(self): # Raise useful error is console type is None. + self.flags(vnc_enabled=True) + self.flags(enabled=False, group='spice') + instance = jsonutils.to_primitive(self._create_fake_instance()) self.compute.run_instance(self.context, instance=instance) @@ -1375,6 +1387,47 @@ class ComputeTestCase(BaseTestCase): self.context, None, instance=instance) self.compute.terminate_instance(self.context, instance=instance) + def test_spicehtml5_spice_console(self): + # Make sure we can a spice console for an instance. + self.flags(vnc_enabled=False) + self.flags(enabled=True, group='spice') + + instance = jsonutils.to_primitive(self._create_fake_instance()) + self.compute.run_instance(self.context, instance=instance) + + # Try with the full instance + console = self.compute.get_spice_console(self.context, 'spice-html5', + instance=instance) + self.assert_(console) + + self.compute.terminate_instance(self.context, instance=instance) + + def test_invalid_spice_console_type(self): + # Raise useful error if console type is an unrecognised string + self.flags(vnc_enabled=False) + self.flags(enabled=True, group='spice') + + instance = jsonutils.to_primitive(self._create_fake_instance()) + self.compute.run_instance(self.context, instance=instance) + + self.assertRaises(exception.ConsoleTypeInvalid, + self.compute.get_spice_console, + self.context, 'invalid', instance=instance) + self.compute.terminate_instance(self.context, instance=instance) + + def test_missing_spice_console_type(self): + # Raise useful error is console type is None + self.flags(vnc_enabled=False) + self.flags(enabled=True, group='spice') + + instance = jsonutils.to_primitive(self._create_fake_instance()) + self.compute.run_instance(self.context, instance=instance) + + self.assertRaises(exception.ConsoleTypeInvalid, + self.compute.get_spice_console, + self.context, None, instance=instance) + self.compute.terminate_instance(self.context, instance=instance) + def test_diagnostics(self): # Make sure we can get diagnostics for an instance. expected_diagnostic = {'cpu0_time': 17300000000, @@ -5512,6 +5565,50 @@ class ComputeAPITestCase(BaseTestCase): db.instance_destroy(self.context, instance['uuid']) + def test_spice_console(self): + # Make sure we can a spice console for an instance. + + fake_instance = {'uuid': 'fake_uuid', + 'host': 'fake_compute_host'} + fake_console_type = "spice-html5" + fake_connect_info = {'token': 'fake_token', + 'console_type': fake_console_type, + 'host': 'fake_console_host', + 'port': 'fake_console_port', + 'internal_access_path': 'fake_access_path'} + fake_connect_info2 = copy.deepcopy(fake_connect_info) + fake_connect_info2['access_url'] = 'fake_console_url' + + self.mox.StubOutWithMock(rpc, 'call') + + rpc_msg1 = {'method': 'get_spice_console', + 'args': {'instance': fake_instance, + 'console_type': fake_console_type}, + 'version': '2.24'} + rpc_msg2 = {'method': 'authorize_console', + 'args': fake_connect_info, + 'version': '1.0'} + + rpc.call(self.context, 'compute.%s' % fake_instance['host'], + rpc_msg1, None).AndReturn(fake_connect_info2) + rpc.call(self.context, CONF.consoleauth_topic, + rpc_msg2, None).AndReturn(None) + + self.mox.ReplayAll() + + console = self.compute_api.get_spice_console(self.context, + fake_instance, fake_console_type) + self.assertEqual(console, {'url': 'fake_console_url'}) + + def test_get_spice_console_no_host(self): + instance = self._create_fake_instance(params={'host': ''}) + + self.assertRaises(exception.InstanceNotReady, + self.compute_api.get_spice_console, + self.context, instance, 'spice') + + db.instance_destroy(self.context, instance['uuid']) + def test_get_backdoor_port(self): # Test api call to get backdoor_port. fake_backdoor_port = 59697 diff --git a/nova/tests/compute/test_rpcapi.py b/nova/tests/compute/test_rpcapi.py index 00b90ea65a5d..b81e049bfc1a 100644 --- a/nova/tests/compute/test_rpcapi.py +++ b/nova/tests/compute/test_rpcapi.py @@ -165,6 +165,11 @@ class ComputeRpcAPITestCase(test.TestCase): self._test_compute_api('get_vnc_console', 'call', instance=self.fake_instance, console_type='type') + def test_get_spice_console(self): + self._test_compute_api('get_spice_console', 'call', + instance=self.fake_instance, console_type='type', + version='2.24') + def test_host_maintenance_mode(self): self._test_compute_api('host_maintenance_mode', 'call', host_param='param', mode='mode', host='host') diff --git a/nova/tests/fake_policy.py b/nova/tests/fake_policy.py index 15890cdcd50d..acefa856cd2f 100644 --- a/nova/tests/fake_policy.py +++ b/nova/tests/fake_policy.py @@ -42,6 +42,7 @@ policy_data = """ "compute:unlock": "", "compute:get_vnc_console": "", + "compute:get_spice_console": "", "compute:get_console_output": "", "compute:associate_floating_ip": "", diff --git a/nova/tests/fakelibvirt.py b/nova/tests/fakelibvirt.py index 8d9561c7eca6..a573b7d1c93c 100644 --- a/nova/tests/fakelibvirt.py +++ b/nova/tests/fakelibvirt.py @@ -414,6 +414,7 @@ class Domain(object): +