diff options
56 files changed, 3258 insertions, 662 deletions
diff --git a/.bzrignore b/.bzrignore index d22b62629..b751ad825 100644 --- a/.bzrignore +++ b/.bzrignore @@ -14,3 +14,8 @@ CA/newcerts/*.pem CA/private/cakey.pem nova/vcsversion.py *.DS_Store +.project +.pydevproject +clean.sqlite +run_tests.log +tests.sqlite @@ -12,6 +12,7 @@ Chiradeep Vittal <chiradeep@cloud.com> Chmouel Boudjnah <chmouel@chmouel.com> Chris Behrens <cbehrens@codestud.com> Christian Berendt <berendt@b1-systems.de> +Chuck Short <zulcss@ubuntu.com> Cory Wright <corywright@gmail.com> Dan Prince <dan.prince@rackspace.com> David Pravec <David.Pravec@danix.org> diff --git a/bin/nova-ajax-console-proxy b/bin/nova-ajax-console-proxy index 0342c620a..d88f59e40 100755 --- a/bin/nova-ajax-console-proxy +++ b/bin/nova-ajax-console-proxy @@ -108,17 +108,17 @@ class AjaxConsoleProxy(object): return "Server Error" def register_listeners(self): - class Callback: - def __call__(self, data, message): - if data['method'] == 'authorize_ajax_console': - AjaxConsoleProxy.tokens[data['args']['token']] = \ - {'args': data['args'], 'last_activity': time.time()} + class TopicProxy(): + @staticmethod + def authorize_ajax_console(context, **kwargs): + AjaxConsoleProxy.tokens[kwargs['token']] = \ + {'args': kwargs, 'last_activity': time.time()} conn = rpc.Connection.instance(new=True) consumer = rpc.TopicAdapterConsumer( - connection=conn, - topic=FLAGS.ajax_console_proxy_topic) - consumer.register_callback(Callback()) + connection=conn, + proxy=TopicProxy, + topic=FLAGS.ajax_console_proxy_topic) def delete_expired_tokens(): now = time.time() @@ -130,8 +130,7 @@ class AjaxConsoleProxy(object): for k in to_delete: del AjaxConsoleProxy.tokens[k] - utils.LoopingCall(consumer.fetch, auto_ack=True, - enable_callbacks=True).start(0.1) + utils.LoopingCall(consumer.fetch, enable_callbacks=True).start(0.1) utils.LoopingCall(delete_expired_tokens).start(1) if __name__ == '__main__': diff --git a/bin/nova-dhcpbridge b/bin/nova-dhcpbridge index 7ef51feba..f42dfd6b5 100755 --- a/bin/nova-dhcpbridge +++ b/bin/nova-dhcpbridge @@ -48,6 +48,7 @@ flags.DECLARE('auth_driver', 'nova.auth.manager') flags.DECLARE('network_size', 'nova.network.manager') flags.DECLARE('num_networks', 'nova.network.manager') flags.DECLARE('update_dhcp_on_disassociate', 'nova.network.manager') +flags.DEFINE_string('dnsmasq_interface', 'br0', 'Default Dnsmasq interface') LOG = logging.getLogger('nova.dhcpbridge') @@ -103,7 +104,8 @@ def main(): utils.default_flagfile(flagfile) argv = FLAGS(sys.argv) logging.setup() - interface = os.environ.get('DNSMASQ_INTERFACE', 'br0') + # check ENV first so we don't break any older deploys + interface = os.environ.get('DNSMASQ_INTERFACE', FLAGS.dnsmasq_interface) if int(os.environ.get('TESTING', '0')): from nova.tests import fake_flags action = argv[1] diff --git a/bin/nova-manage b/bin/nova-manage index cf0caf47e..6789efba8 100755 --- a/bin/nova-manage +++ b/bin/nova-manage @@ -902,7 +902,7 @@ class ImageCommands(object): 'disk_format': disk_format, 'container_format': container_format, 'properties': {'image_state': 'available', - 'owner': owner, + 'owner_id': owner, 'type': image_type, 'architecture': architecture, 'image_location': 'local', @@ -980,7 +980,7 @@ class ImageCommands(object): 'is_public': True, 'name': old['imageId'], 'properties': {'image_state': old['imageState'], - 'owner': old['imageOwnerId'], + 'owner_id': old['imageOwnerId'], 'architecture': old['architecture'], 'type': old['type'], 'image_location': old['imageLocation'], @@ -1098,8 +1098,8 @@ def main(): script_name = argv.pop(0) if len(argv) < 1: print script_name + " category action [<args>]" - print "Available categories:" - for k, _ in CATEGORIES: + print _("Available categories:") + for k, _v in CATEGORIES: print "\t%s" % k sys.exit(2) category = argv.pop(0) @@ -1110,7 +1110,7 @@ def main(): actions = methods_of(command_object) if len(argv) < 1: print script_name + " category action [<args>]" - print "Available actions for %s category:" % category + print _("Available actions for %s category:") % category for k, _v in actions: print "\t%s" % k sys.exit(2) @@ -1122,9 +1122,12 @@ def main(): fn(*argv) sys.exit(0) except TypeError: - print "Possible wrong number of arguments supplied" + print _("Possible wrong number of arguments supplied") print "%s %s: %s" % (category, action, fn.__doc__) raise + except Exception: + print _("Command failed, please check log for more info") + raise if __name__ == '__main__': main() diff --git a/bin/nova-vncproxy b/bin/nova-vncproxy new file mode 100755 index 000000000..ccb97e3a3 --- /dev/null +++ b/bin/nova-vncproxy @@ -0,0 +1,101 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2010 Openstack, LLC. +# 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. + +"""VNC Console Proxy Server.""" + +import eventlet +import gettext +import os +import sys + +possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]), + os.pardir, + os.pardir)) +if os.path.exists(os.path.join(possible_topdir, 'nova', '__init__.py')): + sys.path.insert(0, possible_topdir) + +gettext.install('nova', unicode=1) + +from nova import flags +from nova import log as logging +from nova import service +from nova import utils +from nova import wsgi +from nova import version +from nova.vnc import auth +from nova.vnc import proxy + + +LOG = logging.getLogger('nova.vnc-proxy') + + +FLAGS = flags.FLAGS +flags.DEFINE_string('vncproxy_wwwroot', '/var/lib/nova/noVNC/', + 'Full path to noVNC directory') +flags.DEFINE_boolean('vnc_debug', False, + 'Enable debugging features, like token bypassing') +flags.DEFINE_integer('vncproxy_port', 6080, + 'Port that the VNC proxy should bind to') +flags.DEFINE_string('vncproxy_host', '0.0.0.0', + 'Address that the VNC proxy should bind to') +flags.DEFINE_integer('vnc_token_ttl', 300, + 'How many seconds before deleting tokens') +flags.DEFINE_string('vncproxy_manager', 'nova.vnc.auth.VNCProxyAuthManager', + 'Manager for vncproxy auth') + +flags.DEFINE_flag(flags.HelpFlag()) +flags.DEFINE_flag(flags.HelpshortFlag()) +flags.DEFINE_flag(flags.HelpXMLFlag()) + + +if __name__ == "__main__": + utils.default_flagfile() + FLAGS(sys.argv) + logging.setup() + + LOG.audit(_("Starting nova-vnc-proxy node (version %s)"), + version.version_string_with_vcs()) + + if not (os.path.exists(FLAGS.vncproxy_wwwroot) and + os.path.exists(FLAGS.vncproxy_wwwroot + '/vnc_auto.html')): + LOG.info(_("Missing vncproxy_wwwroot (version %s)"), + FLAGS.vncproxy_wwwroot) + LOG.info(_("You need a slightly modified version of noVNC " + "to work with the nova-vnc-proxy")) + LOG.info(_("Check out the most recent nova noVNC code: %s"), + "git://github.com/sleepsonthefloor/noVNC.git") + LOG.info(_("And drop it in %s"), FLAGS.vncproxy_wwwroot) + exit(1) + + app = proxy.WebsocketVNCProxy(FLAGS.vncproxy_wwwroot) + + LOG.audit(_("Allowing access to the following files: %s"), + app.get_whitelist()) + + with_logging = auth.LoggingMiddleware(app) + + if FLAGS.vnc_debug: + with_auth = proxy.DebugMiddleware(with_logging) + else: + with_auth = auth.VNCNovaAuthMiddleware(with_logging) + + service.serve() + + server = wsgi.Server() + server.start(with_auth, FLAGS.vncproxy_port, host=FLAGS.vncproxy_host) + server.wait() diff --git a/doc/source/runnova/vncconsole.rst b/doc/source/runnova/vncconsole.rst new file mode 100644 index 000000000..c1fe9be39 --- /dev/null +++ b/doc/source/runnova/vncconsole.rst @@ -0,0 +1,76 @@ +.. + Copyright 2010-2011 United States Government as represented by the + Administrator of the National Aeronautics and Space Administration. + 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. + +Getting Started with the VNC Proxy +================================== + +The VNC Proxy is an OpenStack component that allows users of Nova to access +their instances through a websocket enabled browser (like Google Chrome). + +A VNC Connection works like so: + +* User connects over an api and gets a url like http://ip:port/?token=xyz +* User pastes url in browser +* Browser connects to VNC Proxy though a websocket enabled client like noVNC +* VNC Proxy authorizes users token, maps the token to a host and port of an + instance's VNC server +* VNC Proxy initiates connection to VNC server, and continues proxying until + the session ends + + +Configuring the VNC Proxy +------------------------- +nova-vncproxy requires a websocket enabled html client to work properly. At +this time, the only tested client is a slightly modified fork of noVNC, which +you can at find http://github.com/openstack/noVNC.git + +.. todo:: add instruction for installing from package + +noVNC must be in the location specified by --vncproxy_wwwroot, which defaults +to /var/lib/nova/noVNC. nova-vncproxy will fail to launch until this code +is properly installed. + +By default, nova-vncproxy binds 0.0.0.0:6080. This can be configured with: + +* --vncproxy_port=[port] +* --vncproxy_host=[host] + + +Enabling VNC Consoles in Nova +----------------------------- +At the moment, VNC support is supported only when using libvirt. To enable VNC +Console, configure the following flags: + +* --vnc_console_proxy_url=http://[proxy_host]:[proxy_port] - proxy_port + defaults to 6080. This url must point to nova-vncproxy +* --vnc_enabled=[True|False] - defaults to True. If this flag is not set your + instances will launch without vnc support. + + +Getting an instance's VNC Console +--------------------------------- +You can access an instance's VNC Console url in the following methods: + +* Using the direct api: + eg: 'stack --user=admin --project=admin compute get_vnc_console instance_id=1' +* Support for Dashboard, and the Openstack API will be forthcoming + + +Accessing VNC Consoles without a web browser +-------------------------------------------- +At the moment, VNC Consoles are only supported through the web browser, but +more general VNC support is in the works. diff --git a/nova/api/ec2/cloud.py b/nova/api/ec2/cloud.py index 9e34d3317..7ba8dfbea 100644 --- a/nova/api/ec2/cloud.py +++ b/nova/api/ec2/cloud.py @@ -536,6 +536,13 @@ class CloudController(object): return self.compute_api.get_ajax_console(context, instance_id=instance_id) + def get_vnc_console(self, context, instance_id, **kwargs): + """Returns vnc browser url. Used by OS dashboard.""" + ec2_id = instance_id + instance_id = ec2utils.ec2_id_to_id(ec2_id) + return self.compute_api.get_vnc_console(context, + instance_id=instance_id) + def describe_volumes(self, context, volume_id=None, **kwargs): if volume_id: volumes = [] diff --git a/nova/api/openstack/__init__.py b/nova/api/openstack/__init__.py index 149abfeb8..7545eb0c9 100644 --- a/nova/api/openstack/__init__.py +++ b/nova/api/openstack/__init__.py @@ -111,16 +111,11 @@ class APIRouter(wsgi.Router): parent_resource=dict(member_name='server', collection_name='servers')) - mapper.resource("image", "images", controller=images.Controller(), - collection={'detail': 'GET'}) - - mapper.resource("shared_ip_group", "shared_ip_groups", - collection={'detail': 'GET'}, - controller=shared_ip_groups.Controller()) - _limits = limits.LimitsController() mapper.resource("limit", "limits", controller=_limits) + super(APIRouter, self).__init__(mapper) + class APIRouterV10(APIRouter): """Define routes specific to OpenStack API V1.0.""" @@ -132,10 +127,18 @@ class APIRouterV10(APIRouter): collection={'detail': 'GET'}, member=self.server_members) + mapper.resource("image", "images", + controller=images.ControllerV10(), + collection={'detail': 'GET'}) + mapper.resource("flavor", "flavors", controller=flavors.ControllerV10(), collection={'detail': 'GET'}) + mapper.resource("shared_ip_group", "shared_ip_groups", + collection={'detail': 'GET'}, + controller=shared_ip_groups.Controller()) + mapper.resource("backup_schedule", "backup_schedule", controller=backup_schedules.Controller(), parent_resource=dict(member_name='server', @@ -152,6 +155,10 @@ class APIRouterV11(APIRouter): collection={'detail': 'GET'}, member=self.server_members) + mapper.resource("image", "images", + controller=images.ControllerV11(), + collection={'detail': 'GET'}) + mapper.resource("image_meta", "meta", controller=image_metadata.Controller(), parent_resource=dict(member_name='image', diff --git a/nova/api/openstack/common.py b/nova/api/openstack/common.py index 8cad1273a..75aeb0a5f 100644 --- a/nova/api/openstack/common.py +++ b/nova/api/openstack/common.py @@ -21,6 +21,11 @@ import webob from nova import exception from nova import flags +from nova import log as logging + + +LOG = logging.getLogger('common') + FLAGS = flags.FLAGS @@ -121,4 +126,5 @@ def get_id_from_href(href): try: return int(urlparse(href).path.split('/')[-1]) except: + LOG.debug(_("Error extracting id from href: %s") % href) raise webob.exc.HTTPBadRequest(_('could not parse id from href')) diff --git a/nova/api/openstack/contrib/__init__.py b/nova/api/openstack/contrib/__init__.py new file mode 100644 index 000000000..b42a1d89d --- /dev/null +++ b/nova/api/openstack/contrib/__init__.py @@ -0,0 +1,22 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 Justin Santa Barbara +# 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 datetime + +"""Contrib contains extensions that are shipped with nova. + +It can't be called 'extensions' because that causes namespacing problems. + +""" diff --git a/nova/api/openstack/contrib/volumes.py b/nova/api/openstack/contrib/volumes.py new file mode 100644 index 000000000..6efacce52 --- /dev/null +++ b/nova/api/openstack/contrib/volumes.py @@ -0,0 +1,336 @@ +# Copyright 2011 Justin Santa Barbara +# 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. + +"""The volumes extension.""" + +from webob import exc + +from nova import compute +from nova import exception +from nova import flags +from nova import log as logging +from nova import volume +from nova import wsgi +from nova.api.openstack import common +from nova.api.openstack import extensions +from nova.api.openstack import faults + + +LOG = logging.getLogger("nova.api.volumes") + + +FLAGS = flags.FLAGS + + +def _translate_volume_detail_view(context, vol): + """Maps keys for volumes details view.""" + + d = _translate_volume_summary_view(context, vol) + + # No additional data / lookups at the moment + + return d + + +def _translate_volume_summary_view(context, vol): + """Maps keys for volumes summary view.""" + d = {} + + d['id'] = vol['id'] + d['status'] = vol['status'] + d['size'] = vol['size'] + d['availabilityZone'] = vol['availability_zone'] + d['createdAt'] = vol['created_at'] + + if vol['attach_status'] == 'attached': + d['attachments'] = [_translate_attachment_detail_view(context, vol)] + else: + d['attachments'] = [{}] + + d['displayName'] = vol['display_name'] + d['displayDescription'] = vol['display_description'] + return d + + +class VolumeController(wsgi.Controller): + """The Volumes API controller for the OpenStack API.""" + + _serialization_metadata = { + 'application/xml': { + "attributes": { + "volume": [ + "id", + "status", + "size", + "availabilityZone", + "createdAt", + "displayName", + "displayDescription", + ]}}} + + def __init__(self): + self.volume_api = volume.API() + super(VolumeController, self).__init__() + + def show(self, req, id): + """Return data about the given volume.""" + context = req.environ['nova.context'] + + try: + vol = self.volume_api.get(context, id) + except exception.NotFound: + return faults.Fault(exc.HTTPNotFound()) + + return {'volume': _translate_volume_detail_view(context, vol)} + + def delete(self, req, id): + """Delete a volume.""" + context = req.environ['nova.context'] + + LOG.audit(_("Delete volume with id: %s"), id, context=context) + + try: + self.volume_api.delete(context, volume_id=id) + except exception.NotFound: + return faults.Fault(exc.HTTPNotFound()) + return exc.HTTPAccepted() + + def index(self, req): + """Returns a summary list of volumes.""" + return self._items(req, entity_maker=_translate_volume_summary_view) + + def detail(self, req): + """Returns a detailed list of volumes.""" + return self._items(req, entity_maker=_translate_volume_detail_view) + + def _items(self, req, entity_maker): + """Returns a list of volumes, transformed through entity_maker.""" + context = req.environ['nova.context'] + + volumes = self.volume_api.get_all(context) + limited_list = common.limited(volumes, req) + res = [entity_maker(context, vol) for vol in limited_list] + return {'volumes': res} + + def create(self, req): + """Creates a new volume.""" + context = req.environ['nova.context'] + + env = self._deserialize(req.body, req.get_content_type()) + if not env: + return faults.Fault(exc.HTTPUnprocessableEntity()) + + vol = env['volume'] + size = vol['size'] + LOG.audit(_("Create volume of %s GB"), size, context=context) + new_volume = self.volume_api.create(context, size, + vol.get('display_name'), + vol.get('display_description')) + + # Work around problem that instance is lazy-loaded... + new_volume['instance'] = None + + retval = _translate_volume_detail_view(context, new_volume) + + return {'volume': retval} + + +def _translate_attachment_detail_view(_context, vol): + """Maps keys for attachment details view.""" + + d = _translate_attachment_summary_view(_context, vol) + + # No additional data / lookups at the moment + + return d + + +def _translate_attachment_summary_view(_context, vol): + """Maps keys for attachment summary view.""" + d = {} + + volume_id = vol['id'] + + # NOTE(justinsb): We use the volume id as the id of the attachment object + d['id'] = volume_id + + d['volumeId'] = volume_id + if vol.get('instance_id'): + d['serverId'] = vol['instance_id'] + if vol.get('mountpoint'): + d['device'] = vol['mountpoint'] + + return d + + +class VolumeAttachmentController(wsgi.Controller): + """The volume attachment API controller for the Openstack API. + + A child resource of the server. Note that we use the volume id + as the ID of the attachment (though this is not guaranteed externally) + + """ + + _serialization_metadata = { + 'application/xml': { + 'attributes': { + 'volumeAttachment': ['id', + 'serverId', + 'volumeId', + 'device']}}} + + def __init__(self): + self.compute_api = compute.API() + self.volume_api = volume.API() + super(VolumeAttachmentController, self).__init__() + + def index(self, req, server_id): + """Returns the list of volume attachments for a given instance.""" + return self._items(req, server_id, + entity_maker=_translate_attachment_summary_view) + + def show(self, req, server_id, id): + """Return data about the given volume attachment.""" + context = req.environ['nova.context'] + + volume_id = id + try: + vol = self.volume_api.get(context, volume_id) + except exception.NotFound: + LOG.debug("volume_id not found") + return faults.Fault(exc.HTTPNotFound()) + + if str(vol['instance_id']) != server_id: + LOG.debug("instance_id != server_id") + return faults.Fault(exc.HTTPNotFound()) + + return {'volumeAttachment': _translate_attachment_detail_view(context, + vol)} + + def create(self, req, server_id): + """Attach a volume to an instance.""" + context = req.environ['nova.context'] + + env = self._deserialize(req.body, req.get_content_type()) + if not env: + return faults.Fault(exc.HTTPUnprocessableEntity()) + + instance_id = server_id + volume_id = env['volumeAttachment']['volumeId'] + device = env['volumeAttachment']['device'] + + msg = _("Attach volume %(volume_id)s to instance %(server_id)s" + " at %(device)s") % locals() + LOG.audit(msg, context=context) + + try: + self.compute_api.attach_volume(context, + instance_id=instance_id, + volume_id=volume_id, + device=device) + except exception.NotFound: + return faults.Fault(exc.HTTPNotFound()) + + # The attach is async + attachment = {} + attachment['id'] = volume_id + attachment['volumeId'] = volume_id + + # NOTE(justinsb): And now, we have a problem... + # The attach is async, so there's a window in which we don't see + # the attachment (until the attachment completes). We could also + # get problems with concurrent requests. I think we need an + # attachment state, and to write to the DB here, but that's a bigger + # change. + # For now, we'll probably have to rely on libraries being smart + + # TODO(justinsb): How do I return "accepted" here? + return {'volumeAttachment': attachment} + + def update(self, _req, _server_id, _id): + """Update a volume attachment. We don't currently support this.""" + return faults.Fault(exc.HTTPBadRequest()) + + def delete(self, req, server_id, id): + """Detach a volume from an instance.""" + context = req.environ['nova.context'] + + volume_id = id + LOG.audit(_("Detach volume %s"), volume_id, context=context) + + try: + vol = self.volume_api.get(context, volume_id) + except exception.NotFound: + return faults.Fault(exc.HTTPNotFound()) + + if str(vol['instance_id']) != server_id: + LOG.debug("instance_id != server_id") + return faults.Fault(exc.HTTPNotFound()) + + self.compute_api.detach_volume(context, + volume_id=volume_id) + + return exc.HTTPAccepted() + + def _items(self, req, server_id, entity_maker): + """Returns a list of attachments, transformed through entity_maker.""" + context = req.environ['nova.context'] + + try: + instance = self.compute_api.get(context, server_id) + except exception.NotFound: + return faults.Fault(exc.HTTPNotFound()) + + volumes = instance['volumes'] + limited_list = common.limited(volumes, req) + res = [entity_maker(context, vol) for vol in limited_list] + return {'volumeAttachments': res} + + +class Volumes(extensions.ExtensionDescriptor): + def get_name(self): + return "Volumes" + + def get_alias(self): + return "VOLUMES" + + def get_description(self): + return "Volumes support" + + def get_namespace(self): + return "http://docs.openstack.org/ext/volumes/api/v1.1" + + def get_updated(self): + return "2011-03-25T00:00:00+00:00" + + def get_resources(self): + resources = [] + + # NOTE(justinsb): No way to provide singular name ('volume') + # Does this matter? + res = extensions.ResourceExtension('volumes', + VolumeController(), + collection_actions={'detail': 'GET'} + ) + resources.append(res) + + res = extensions.ResourceExtension('volume_attachments', + VolumeAttachmentController(), + parent=dict( + member_name='server', + collection_name='servers')) + resources.append(res) + + return resources diff --git a/nova/api/openstack/extensions.py b/nova/api/openstack/extensions.py index b9b7f998d..fb1dccb28 100644 --- a/nova/api/openstack/extensions.py +++ b/nova/api/openstack/extensions.py @@ -1,6 +1,7 @@ # vim: tabstop=4 shiftwidth=4 softtabstop=4 # Copyright 2011 OpenStack LLC. +# Copyright 2011 Justin Santa Barbara # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -16,12 +17,14 @@ # under the License. import imp +import inspect import os import sys import routes import webob.dec import webob.exc +from nova import exception from nova import flags from nova import log as logging from nova import wsgi @@ -34,6 +37,84 @@ LOG = logging.getLogger('extensions') FLAGS = flags.FLAGS +class ExtensionDescriptor(object): + """Base class that defines the contract for extensions. + + Note that you don't have to derive from this class to have a valid + extension; it is purely a convenience. + + """ + + def get_name(self): + """The name of the extension. + + e.g. 'Fox In Socks' + + """ + raise NotImplementedError() + + def get_alias(self): + """The alias for the extension. + + e.g. 'FOXNSOX' + + """ + raise NotImplementedError() + + def get_description(self): + """Friendly description for the extension. + + e.g. 'The Fox In Socks Extension' + + """ + raise NotImplementedError() + + def get_namespace(self): + """The XML namespace for the extension. + + e.g. 'http://www.fox.in.socks/api/ext/pie/v1.0' + + """ + raise NotImplementedError() + + def get_updated(self): + """The timestamp when the extension was last updated. + + e.g. '2011-01-22T13:25:27-06:00' + + """ + # NOTE(justinsb): Not sure of the purpose of this is, vs the XML NS + raise NotImplementedError() + + def get_resources(self): + """List of extensions.ResourceExtension extension objects. + + Resources define new nouns, and are accessible through URLs. + + """ + resources = [] + return resources + + def get_actions(self): + """List of extensions.ActionExtension extension objects. + + Actions are verbs callable from the API. + + """ + actions = [] + return actions + + def get_response_extensions(self): + """List of extensions.ResponseExtension extension objects. + + Response extensions are used to insert information into existing + response data. + + """ + response_exts = [] + return response_exts + + class ActionExtensionController(wsgi.Controller): def __init__(self, application): @@ -94,45 +175,38 @@ class ExtensionController(wsgi.Controller): ext_data['description'] = ext.get_description() ext_data['namespace'] = ext.get_namespace() ext_data['updated'] = ext.get_updated() - ext_data['links'] = [] # TODO: implement extension links + ext_data['links'] = [] # TODO(dprince): implement extension links return ext_data def index(self, req): extensions = [] - for alias, ext in self.extension_manager.extensions.iteritems(): + for _alias, ext in self.extension_manager.extensions.iteritems(): extensions.append(self._translate(ext)) return dict(extensions=extensions) def show(self, req, id): - # NOTE: the extensions alias is used as the 'id' for show + # NOTE(dprince): the extensions alias is used as the 'id' for show ext = self.extension_manager.extensions[id] return self._translate(ext) def delete(self, req, id): - raise faults.Fault(exc.HTTPNotFound()) + raise faults.Fault(webob.exc.HTTPNotFound()) def create(self, req): - raise faults.Fault(exc.HTTPNotFound()) - - def delete(self, req, id): - raise faults.Fault(exc.HTTPNotFound()) + raise faults.Fault(webob.exc.HTTPNotFound()) class ExtensionMiddleware(wsgi.Middleware): - """ - Extensions middleware that intercepts configured routes for extensions. - """ + """Extensions middleware for WSGI.""" @classmethod def factory(cls, global_config, **local_config): - """ paste factory """ + """Paste factory.""" def _factory(app): return cls(app, **local_config) return _factory def _action_ext_controllers(self, application, ext_mgr, mapper): - """ - Return a dict of ActionExtensionController objects by collection - """ + """Return a dict of ActionExtensionController-s by collection.""" action_controllers = {} for action in ext_mgr.get_actions(): if not action.collection in action_controllers.keys(): @@ -151,9 +225,7 @@ class ExtensionMiddleware(wsgi.Middleware): return action_controllers def _response_ext_controllers(self, application, ext_mgr, mapper): - """ - Return a dict of ResponseExtensionController objects by collection - """ + """Returns a dict of ResponseExtensionController-s by collection.""" response_ext_controllers = {} for resp_ext in ext_mgr.get_response_extensions(): if not resp_ext.key in response_ext_controllers.keys(): @@ -212,18 +284,18 @@ class ExtensionMiddleware(wsgi.Middleware): @webob.dec.wsgify(RequestClass=wsgi.Request) def __call__(self, req): - """ - Route the incoming request with router. - """ + """Route the incoming request with router.""" req.environ['extended.app'] = self.application return self._router @staticmethod @webob.dec.wsgify(RequestClass=wsgi.Request) def _dispatch(req): - """ + """Dispatch the request. + Returns the routed WSGI app's response or defers to the extended application. + """ match = req.environ['wsgiorg.routing_args'][1] if not match: @@ -233,10 +305,11 @@ class ExtensionMiddleware(wsgi.Middleware): class ExtensionManager(object): - """ - Load extensions from the configured extension path. - See nova/tests/api/openstack/extensions/foxinsocks.py for an example - extension implementation. + """Load extensions from the configured extension path. + + See nova/tests/api/openstack/extensions/foxinsocks/extension.py for an + example extension implementation. + """ def __init__(self, path): @@ -244,12 +317,10 @@ class ExtensionManager(object): self.path = path self.extensions = {} - self._load_extensions() + self._load_all_extensions() def get_resources(self): - """ - returns a list of ResourceExtension objects - """ + """Returns a list of ResourceExtension objects.""" resources = [] resources.append(ResourceExtension('extensions', ExtensionController(self))) @@ -257,40 +328,37 @@ class ExtensionManager(object): try: resources.extend(ext.get_resources()) except AttributeError: - # NOTE: Extension aren't required to have resource extensions + # NOTE(dprince): Extension aren't required to have resource + # extensions pass return resources def get_actions(self): - """ - returns a list of ActionExtension objects - """ + """Returns a list of ActionExtension objects.""" actions = [] for alias, ext in self.extensions.iteritems(): try: actions.extend(ext.get_actions()) except AttributeError: - # NOTE: Extension aren't required to have action extensions + # NOTE(dprince): Extension aren't required to have action + # extensions pass return actions def get_response_extensions(self): - """ - returns a list of ResponseExtension objects - """ + """Returns a list of ResponseExtension objects.""" response_exts = [] for alias, ext in self.extensions.iteritems(): try: response_exts.extend(ext.get_response_extensions()) except AttributeError: - # NOTE: Extension aren't required to have response extensions + # NOTE(dprince): Extension aren't required to have response + # extensions pass return response_exts def _check_extension(self, extension): - """ - Checks for required methods in extension objects. - """ + """Checks for required methods in extension objects.""" try: LOG.debug(_('Ext name: %s'), extension.get_name()) LOG.debug(_('Ext alias: %s'), extension.get_alias()) @@ -300,43 +368,59 @@ class ExtensionManager(object): except AttributeError as ex: LOG.exception(_("Exception loading extension: %s"), unicode(ex)) - def _load_extensions(self): - """ + def _load_all_extensions(self): + """Load extensions from the configured path. + Load extensions from the configured path. The extension name is constructed from the module_name. If your extension module was named widgets.py the extension class within that module should be 'Widgets'. + In addition, extensions are loaded from the 'contrib' directory. + See nova/tests/api/openstack/extensions/foxinsocks.py for an example extension implementation. + """ - if not os.path.exists(self.path): - return + if os.path.exists(self.path): + self._load_all_extensions_from_path(self.path) - for f in os.listdir(self.path): + contrib_path = os.path.join(os.path.dirname(__file__), "contrib") + if os.path.exists(contrib_path): + self._load_all_extensions_from_path(contrib_path) + + def _load_all_extensions_from_path(self, path): + for f in os.listdir(path): LOG.audit(_('Loading extension file: %s'), f) mod_name, file_ext = os.path.splitext(os.path.split(f)[-1]) - ext_path = os.path.join(self.path, f) + ext_path = os.path.join(path, f) if file_ext.lower() == '.py' and not mod_name.startswith('_'): mod = imp.load_source(mod_name, ext_path) ext_name = mod_name[0].upper() + mod_name[1:] new_ext_class = getattr(mod, ext_name, None) if not new_ext_class: LOG.warn(_('Did not find expected name ' - '"%(ext_name)" in %(file)s'), + '"%(ext_name)s" in %(file)s'), {'ext_name': ext_name, - 'file': ext_path}) + 'file': ext_path}) continue new_ext = new_ext_class() self._check_extension(new_ext) - self.extensions[new_ext.get_alias()] = new_ext + self._add_extension(new_ext) + + def _add_extension(self, ext): + alias = ext.get_alias() + LOG.audit(_('Loaded extension: %s'), alias) + + self._check_extension(ext) + + if alias in self.extensions: + raise exception.Error("Found duplicate extension: %s" % alias) + self.extensions[alias] = ext class ResponseExtension(object): - """ - ResponseExtension objects can be used to add data to responses from - core nova OpenStack API controllers. - """ + """Add data to responses from core nova OpenStack API controllers.""" def __init__(self, method, url_route, handler): self.url_route = url_route @@ -346,10 +430,7 @@ class ResponseExtension(object): class ActionExtension(object): - """ - ActionExtension objects can be used to add custom actions to core nova - nova OpenStack API controllers. - """ + """Add custom actions to core nova OpenStack API controllers.""" def __init__(self, collection, action_name, handler): self.collection = collection @@ -358,10 +439,7 @@ class ActionExtension(object): class ResourceExtension(object): - """ - ResourceExtension objects can be used to add top level resources - to the OpenStack API in nova. - """ + """Add top level resources to the OpenStack API in nova.""" def __init__(self, collection, controller, parent=None, collection_actions={}, member_actions={}): diff --git a/nova/api/openstack/images.py b/nova/api/openstack/images.py index 79852ecc6..e77100d7b 100644 --- a/nova/api/openstack/images.py +++ b/nova/api/openstack/images.py @@ -1,6 +1,4 @@ -# vim: tabstop=4 shiftwidth=4 softtabstop=4 - -# Copyright 2010 OpenStack LLC. +# Copyright 2011 OpenStack LLC. # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -17,7 +15,7 @@ import datetime -from webob import exc +import webob.exc from nova import compute from nova import exception @@ -25,238 +23,133 @@ from nova import flags from nova import log from nova import utils from nova import wsgi -import nova.api.openstack from nova.api.openstack import common from nova.api.openstack import faults -import nova.image.service +from nova.api.openstack.views import images as images_view LOG = log.getLogger('nova.api.openstack.images') - FLAGS = flags.FLAGS -def _translate_keys(item): - """ - Maps key names to Rackspace-like attributes for return - also pares down attributes to those we want - item is a dict - - Note: should be removed when the set of keys expected by the api - and the set of keys returned by the image service are equivalent - - """ - # TODO(tr3buchet): this map is specific to s3 object store, - # replace with a list of keys for _filter_keys later - mapped_keys = {'status': 'imageState', - 'id': 'imageId', - 'name': 'imageLocation'} - - mapped_item = {} - # TODO(tr3buchet): - # this chunk of code works with s3 and the local image service/glance - # when we switch to glance/local image service it can be replaced with - # a call to _filter_keys, and mapped_keys can be changed to a list - try: - for k, v in mapped_keys.iteritems(): - # map s3 fields - mapped_item[k] = item[v] - except KeyError: - # return only the fields api expects - mapped_item = _filter_keys(item, mapped_keys.keys()) - - return mapped_item - - -def _translate_status(item): - """ - Translates status of image to match current Rackspace api bindings - item is a dict - - Note: should be removed when the set of statuses expected by the api - and the set of statuses returned by the image service are equivalent - - """ - status_mapping = { - 'pending': 'queued', - 'decrypting': 'preparing', - 'untarring': 'saving', - 'available': 'active'} - try: - item['status'] = status_mapping[item['status']] - except KeyError: - # TODO(sirp): Performing translation of status (if necessary) here for - # now. Perhaps this should really be done in EC2 API and - # S3ImageService - pass - - -def _filter_keys(item, keys): - """ - Filters all model attributes except for keys - item is a dict - - """ - return dict((k, v) for k, v in item.iteritems() if k in keys) - - -def _convert_image_id_to_hash(image): - if 'imageId' in image: - # Convert EC2-style ID (i-blah) to Rackspace-style (int) - image_id = abs(hash(image['imageId'])) - image['imageId'] = image_id - image['id'] = image_id - - -def _translate_s3_like_images(image_metadata): - """Work-around for leaky S3ImageService abstraction""" - api_metadata = image_metadata.copy() - _convert_image_id_to_hash(api_metadata) - api_metadata = _translate_keys(api_metadata) - _translate_status(api_metadata) - return api_metadata - - -def _translate_from_image_service_to_api(image_metadata): - """Translate from ImageService to OpenStack API style attribute names - - This involves 4 steps: - - 1. Filter out attributes that the OpenStack API doesn't need - - 2. Translate from base image attributes from names used by - BaseImageService to names used by OpenStack API - - 3. Add in any image properties - - 4. Format values according to API spec (for example dates must - look like "2010-08-10T12:00:00Z") - """ - service_metadata = image_metadata.copy() - properties = service_metadata.pop('properties', {}) - - # 1. Filter out unecessary attributes - api_keys = ['id', 'name', 'updated_at', 'created_at', 'status'] - api_metadata = utils.subset_dict(service_metadata, api_keys) - - # 2. Translate base image attributes - api_map = {'updated_at': 'updated', 'created_at': 'created'} - api_metadata = utils.map_dict_keys(api_metadata, api_map) - - # 3. Add in any image properties - # 3a. serverId is used for backups and snapshots - try: - api_metadata['serverId'] = int(properties['instance_id']) - except KeyError: - pass # skip if it's not present - except ValueError: - pass # skip if it's not an integer - - # 3b. Progress special case - # TODO(sirp): ImageService doesn't have a notion of progress yet, so for - # now just fake it - if service_metadata['status'] == 'saving': - api_metadata['progress'] = 0 - - # 4. Format values - # 4a. Format Image Status (API requires uppercase) - api_metadata['status'] = _format_status_for_api(api_metadata['status']) - - # 4b. Format timestamps - for attr in ('created', 'updated'): - if attr in api_metadata: - api_metadata[attr] = _format_datetime_for_api( - api_metadata[attr]) - - return api_metadata - - -def _format_status_for_api(status): - """Return status in a format compliant with OpenStack API""" - mapping = {'queued': 'QUEUED', - 'preparing': 'PREPARING', - 'saving': 'SAVING', - 'active': 'ACTIVE', - 'killed': 'FAILED'} - return mapping[status] - - -def _format_datetime_for_api(datetime_): - """Stringify datetime objects in a format compliant with OpenStack API""" - API_DATETIME_FMT = '%Y-%m-%dT%H:%M:%SZ' - return datetime_.strftime(API_DATETIME_FMT) - - -def _safe_translate(image_metadata): - """Translate attributes for OpenStack API, temporary workaround for - S3ImageService attribute leakage. - """ - # FIXME(sirp): The S3ImageService appears to be leaking implementation - # details, including its internal attribute names, and internal - # `status` values. Working around it for now. - s3_like_image = ('imageId' in image_metadata) - if s3_like_image: - translate = _translate_s3_like_images - else: - translate = _translate_from_image_service_to_api - return translate(image_metadata) - - class Controller(wsgi.Controller): + """Base `wsgi.Controller` for retrieving/displaying images.""" _serialization_metadata = { 'application/xml': { "attributes": { "image": ["id", "name", "updated", "created", "status", - "serverId", "progress"]}}} + "serverId", "progress"], + "link": ["rel", "type", "href"], + }, + }, + } + + def __init__(self, image_service=None, compute_service=None): + """Initialize new `ImageController`. - def __init__(self): - self._service = utils.import_object(FLAGS.image_service) + :param compute_service: `nova.compute.api:API` + :param image_service: `nova.image.service:BaseImageService` + """ + _default_service = utils.import_object(flags.FLAGS.image_service) + + self._compute_service = compute_service or compute.API() + self._image_service = image_service or _default_service def index(self, req): - """Return all public images in brief""" + """Return an index listing of images available to the request. + + :param req: `wsgi.Request` object + """ context = req.environ['nova.context'] - image_metas = self._service.index(context) - image_metas = common.limited(image_metas, req) - return dict(images=image_metas) + images = self._image_service.index(context) + images = common.limited(images, req) + builder = self.get_builder(req).build + return dict(images=[builder(image, detail=False) for image in images]) def detail(self, req): - """Return all public images in detail""" + """Return a detailed index listing of images available to the request. + + :param req: `wsgi.Request` object. + """ context = req.environ['nova.context'] - image_metas = self._service.detail(context) - image_metas = common.limited(image_metas, req) - api_image_metas = [_safe_translate(image_meta) - for image_meta in image_metas] - return dict(images=api_image_metas) + images = self._image_service.detail(context) + images = common.limited(images, req) + builder = self.get_builder(req).build + return dict(images=[builder(image, detail=True) for image in images]) def show(self, req, id): - """Return data about the given image id""" + """Return detailed information about a specific image. + + :param req: `wsgi.Request` object + :param id: Image identifier (integer) + """ context = req.environ['nova.context'] + + try: + image_id = int(id) + except ValueError: + explanation = _("Image not found.") + raise faults.Fault(webob.exc.HTTPNotFound(explanation=explanation)) + try: - image_id = common.get_image_id_from_image_hash( - self._service, context, id) + image = self._image_service.show(context, image_id) except exception.NotFound: - raise faults.Fault(exc.HTTPNotFound()) + explanation = _("Image '%d' not found.") % (image_id) + raise faults.Fault(webob.exc.HTTPNotFound(explanation=explanation)) - image_meta = self._service.show(context, image_id) - api_image_meta = _safe_translate(image_meta) - return dict(image=api_image_meta) + return dict(image=self.get_builder(req).build(image, detail=True)) def delete(self, req, id): - # Only public images are supported for now. - raise faults.Fault(exc.HTTPNotFound()) + """Delete an image, if allowed. + + :param req: `wsgi.Request` object + :param id: Image identifier (integer) + """ + image_id = id + context = req.environ['nova.context'] + self._image_service.delete(context, image_id) + return webob.exc.HTTPNoContent() def create(self, req): + """Snapshot a server instance and save the image. + + :param req: `wsgi.Request` object + """ context = req.environ['nova.context'] - env = self._deserialize(req.body, req.get_content_type()) - instance_id = env["image"]["serverId"] - name = env["image"]["name"] - image_meta = compute.API().snapshot( - context, instance_id, name) - api_image_meta = _safe_translate(image_meta) - return dict(image=api_image_meta) - - def update(self, req, id): - # Users may not modify public images, and that's all that - # we support for now. - raise faults.Fault(exc.HTTPNotFound()) + content_type = req.get_content_type() + image = self._deserialize(req.body, content_type) + + if not image: + raise webob.exc.HTTPBadRequest() + + try: + server_id = image["image"]["serverId"] + image_name = image["image"]["name"] + except KeyError: + raise webob.exc.HTTPBadRequest() + + image = self._compute_service.snapshot(context, server_id, image_name) + return self.get_builder(req).build(image, detail=True) + + def get_builder(self, request): + """Indicates that you must use a Controller subclass.""" + raise NotImplementedError + + +class ControllerV10(Controller): + """Version 1.0 specific controller logic.""" + + def get_builder(self, request): + """Property to get the ViewBuilder class we need to use.""" + base_url = request.application_url + return images_view.ViewBuilderV10(base_url) + + +class ControllerV11(Controller): + """Version 1.1 specific controller logic.""" + + def get_builder(self, request): + """Property to get the ViewBuilder class we need to use.""" + base_url = request.application_url + return images_view.ViewBuilderV11(base_url) diff --git a/nova/api/openstack/servers.py b/nova/api/openstack/servers.py index 4400a68a1..f7696d918 100644 --- a/nova/api/openstack/servers.py +++ b/nova/api/openstack/servers.py @@ -499,7 +499,7 @@ class Controller(wsgi.Controller): @scheduler_api.redirect_handler def get_ajax_console(self, req, id): - """ Returns a url to an instance's ajaxterm console. """ + """Returns a url to an instance's ajaxterm console.""" try: self.compute_api.get_ajax_console(req.environ['nova.context'], int(id)) @@ -508,6 +508,16 @@ class Controller(wsgi.Controller): return exc.HTTPAccepted() @scheduler_api.redirect_handler + def get_vnc_console(self, req, id): + """Returns a url to an instance's ajaxterm console.""" + try: + self.compute_api.get_vnc_console(req.environ['nova.context'], + int(id)) + except exception.NotFound: + return faults.Fault(exc.HTTPNotFound()) + return exc.HTTPAccepted() + + @scheduler_api.redirect_handler def diagnostics(self, req, id): """Permit Admins to retrieve server diagnostics.""" ctxt = req.environ["nova.context"] diff --git a/nova/api/openstack/shared_ip_groups.py b/nova/api/openstack/shared_ip_groups.py index 5d78f9377..ee7991d7f 100644 --- a/nova/api/openstack/shared_ip_groups.py +++ b/nova/api/openstack/shared_ip_groups.py @@ -42,11 +42,11 @@ class Controller(wsgi.Controller): def index(self, req): """ Returns a list of Shared IP Groups for the user """ - return dict(sharedIpGroups=[]) + raise faults.Fault(exc.HTTPNotImplemented()) def show(self, req, id): """ Shows in-depth information on a specific Shared IP Group """ - return _translate_keys({}) + raise faults.Fault(exc.HTTPNotImplemented()) def update(self, req, id): """ You can't update a Shared IP Group """ @@ -58,7 +58,7 @@ class Controller(wsgi.Controller): def detail(self, req): """ Returns a complete list of Shared IP Groups """ - return _translate_detail_keys({}) + raise faults.Fault(exc.HTTPNotImplemented()) def create(self, req): """ Creates a new Shared IP group """ diff --git a/nova/api/openstack/versions.py b/nova/api/openstack/versions.py index 33f1dd628..3f9d91934 100644 --- a/nova/api/openstack/versions.py +++ b/nova/api/openstack/versions.py @@ -15,8 +15,8 @@ # License for the specific language governing permissions and limitations # under the License. +import webob import webob.dec -import webob.exc from nova import wsgi import nova.api.openstack.views.versions @@ -51,4 +51,10 @@ class Versions(wsgi.Application): } content_type = req.best_match_content_type() - return wsgi.Serializer(metadata).serialize(response, content_type) + body = wsgi.Serializer(metadata).serialize(response, content_type) + + response = webob.Response() + response.content_type = content_type + response.body = body + + return response diff --git a/nova/api/openstack/views/images.py b/nova/api/openstack/views/images.py index a6c6ad7d1..3807fa95f 100644 --- a/nova/api/openstack/views/images.py +++ b/nova/api/openstack/views/images.py @@ -15,20 +15,100 @@ # License for the specific language governing permissions and limitations # under the License. -from nova.api.openstack import common +import os.path class ViewBuilder(object): - def __init__(self): - pass + """Base class for generating responses to OpenStack API image requests.""" - def build(self, image_obj): - raise NotImplementedError() + def __init__(self, base_url): + """Initialize new `ViewBuilder`.""" + self._url = base_url + def _format_dates(self, image): + """Update all date fields to ensure standardized formatting.""" + for attr in ['created_at', 'updated_at', 'deleted_at']: + if image.get(attr) is not None: + image[attr] = image[attr].strftime('%Y-%m-%dT%H:%M:%SZ') -class ViewBuilderV11(ViewBuilder): - def __init__(self, base_url): - self.base_url = base_url + def _format_status(self, image): + """Update the status field to standardize format.""" + status_mapping = { + 'pending': 'queued', + 'decrypting': 'preparing', + 'untarring': 'saving', + 'available': 'active', + 'killed': 'failed', + } + + try: + image['status'] = status_mapping[image['status']].upper() + except KeyError: + image['status'] = image['status'].upper() def generate_href(self, image_id): - return "%s/images/%s" % (self.base_url, image_id) + """Return an href string pointing to this object.""" + return os.path.join(self._url, "images", str(image_id)) + + def build(self, image_obj, detail=False): + """Return a standardized image structure for display by the API.""" + properties = image_obj.get("properties", {}) + + self._format_dates(image_obj) + + if "status" in image_obj: + self._format_status(image_obj) + + image = { + "id": image_obj["id"], + "name": image_obj["name"], + } + + if "instance_id" in properties: + try: + image["serverId"] = int(properties["instance_id"]) + except ValueError: + pass + + if detail: + image.update({ + "created": image_obj["created_at"], + "updated": image_obj["updated_at"], + "status": image_obj["status"], + }) + + if image["status"] == "SAVING": + image["progress"] = 0 + + return image + + +class ViewBuilderV10(ViewBuilder): + """OpenStack API v1.0 Image Builder""" + pass + + +class ViewBuilderV11(ViewBuilder): + """OpenStack API v1.1 Image Builder""" + + def build(self, image_obj, detail=False): + """Return a standardized image structure for display by the API.""" + image = ViewBuilder.build(self, image_obj, detail) + href = self.generate_href(image_obj["id"]) + + image["links"] = [{ + "rel": "self", + "href": href, + }, + { + "rel": "bookmark", + "type": "application/json", + "href": href, + }, + { + "rel": "bookmark", + "type": "application/xml", + "href": href, + }] + + return image diff --git a/nova/compute/api.py b/nova/compute/api.py index 266cbe677..1dbd73f8f 100644 --- a/nova/compute/api.py +++ b/nova/compute/api.py @@ -608,6 +608,25 @@ class API(base.Base): return {'url': '%s/?token=%s' % (FLAGS.ajax_console_proxy_url, output['token'])} + def get_vnc_console(self, context, instance_id): + """Get a url to a VNC Console.""" + instance = self.get(context, instance_id) + output = self._call_compute_message('get_vnc_console', + context, + instance_id) + rpc.call(context, '%s' % FLAGS.vncproxy_topic, + {'method': 'authorize_vnc_console', + 'args': {'token': output['token'], + 'host': output['host'], + 'port': output['port']}}) + + # hostignore and portignore are compatability params for noVNC + return {'url': '%s/vnc_auto.html?token=%s&host=%s&port=%s' % ( + FLAGS.vncproxy_url, + output['token'], + 'hostignore', + 'portignore')} + def get_console_output(self, context, instance_id): """Get console output for an an instance""" return self._call_compute_message('get_console_output', diff --git a/nova/compute/manager.py b/nova/compute/manager.py index e0a5e2b3f..08b772517 100644 --- a/nova/compute/manager.py +++ b/nova/compute/manager.py @@ -723,6 +723,15 @@ class ComputeManager(manager.SchedulerDependentManager): return self.driver.get_ajax_console(instance_ref) + @exception.wrap_exception + def get_vnc_console(self, context, instance_id): + """Return connection information for an vnc console.""" + context = context.elevated() + LOG.debug(_("instance %s: getting vnc console"), instance_id) + instance_ref = self.db.instance_get(context, instance_id) + + return self.driver.get_vnc_console(instance_ref) + @checks_instance_lock def attach_volume(self, context, instance_id, volume_id, mountpoint): """Attach a volume to an instance.""" diff --git a/nova/image/fake.py b/nova/image/fake.py new file mode 100644 index 000000000..08302d6eb --- /dev/null +++ b/nova/image/fake.py @@ -0,0 +1,113 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 Justin Santa Barbara +# 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. +"""Implementation of an fake image service""" + +import copy +import datetime + +from nova import exception +from nova import flags +from nova import log as logging +from nova.image import service + + +LOG = logging.getLogger('nova.image.fake') + + +FLAGS = flags.FLAGS + + +class FakeImageService(service.BaseImageService): + """Mock (fake) image service for unit testing.""" + + def __init__(self): + self.images = {} + # NOTE(justinsb): The OpenStack API can't upload an image? + # So, make sure we've got one.. + timestamp = datetime.datetime(2011, 01, 01, 01, 02, 03) + image = {'id': '123456', + 'name': 'fakeimage123456', + 'created_at': timestamp, + 'updated_at': timestamp, + 'status': 'active', + 'type': 'machine', + 'properties': {'kernel_id': FLAGS.null_kernel, + 'ramdisk_id': FLAGS.null_kernel, + 'disk_format': 'ami'} + } + self.create(None, image) + super(FakeImageService, self).__init__() + + def index(self, context): + """Returns list of images.""" + return copy.deepcopy(self.images.values()) + + def detail(self, context): + """Return list of detailed image information.""" + return copy.deepcopy(self.images.values()) + + def show(self, context, image_id): + """Get data about specified image. + + Returns a dict containing image data for the given opaque image id. + + """ + image_id = int(image_id) + image = self.images.get(image_id) + if image: + return copy.deepcopy(image) + LOG.warn("Unable to find image id %s. Have images: %s", + image_id, self.images) + raise exception.NotFound + + def create(self, context, data): + """Store the image data and return the new image id. + + :raises Duplicate if the image already exist. + + """ + image_id = int(data['id']) + if self.images.get(image_id): + raise exception.Duplicate() + + self.images[image_id] = copy.deepcopy(data) + + def update(self, context, image_id, data): + """Replace the contents of the given image with the new data. + + :raises NotFound if the image does not exist. + + """ + image_id = int(image_id) + if not self.images.get(image_id): + raise exception.NotFound + self.images[image_id] = copy.deepcopy(data) + + def delete(self, context, image_id): + """Delete the given image. + + :raises NotFound if the image does not exist. + + """ + image_id = int(image_id) + removed = self.images.pop(image_id, None) + if not removed: + raise exception.NotFound + + def delete_all(self): + """Clears out all images.""" + self.images.clear() diff --git a/nova/image/glance.py b/nova/image/glance.py index be9805b69..fdf468594 100644 --- a/nova/image/glance.py +++ b/nova/image/glance.py @@ -220,7 +220,7 @@ def _convert_timestamps_to_datetimes(image_meta): Returns image with known timestamp fields converted to datetime objects """ for attr in ['created_at', 'updated_at', 'deleted_at']: - if image_meta.get(attr) is not None: + if image_meta.get(attr): image_meta[attr] = _parse_glance_iso8601_timestamp( image_meta[attr]) return image_meta @@ -230,8 +230,13 @@ def _parse_glance_iso8601_timestamp(timestamp): """ Parse a subset of iso8601 timestamps into datetime objects """ - GLANCE_FMT = "%Y-%m-%dT%H:%M:%S" - ISO_FMT = "%Y-%m-%dT%H:%M:%S.%f" - # FIXME(sirp): Glance is not returning in ISO format, we should fix Glance - # to do so, and then switch to parsing it here - return datetime.datetime.strptime(timestamp, GLANCE_FMT) + iso_formats = ["%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"] + + for iso_format in iso_formats: + try: + return datetime.datetime.strptime(timestamp, iso_format) + except ValueError: + pass + + raise ValueError(_("%(timestamp)s does not follow any of the " + "signatures: %(ISO_FORMATS)s") % locals()) diff --git a/nova/image/s3.py b/nova/image/s3.py index 85a2c651c..ddec5f3aa 100644 --- a/nova/image/s3.py +++ b/nova/image/s3.py @@ -31,6 +31,7 @@ from xml.etree import ElementTree import boto.s3.connection +from nova import crypto from nova import exception from nova import flags from nova import utils @@ -210,7 +211,7 @@ class S3ImageService(service.BaseImageService): # FIXME(vish): grab key from common service so this can run on # any host. - cloud_pk = os.path.join(FLAGS.ca_path, "private/cakey.pem") + cloud_pk = crypto.key_path(context.project_id) decrypted_filename = os.path.join(image_path, 'image.tar.gz') self._decrypt_image(encrypted_filename, encrypted_key, diff --git a/nova/network/linux_net.py b/nova/network/linux_net.py index 06b05366a..d11d21dad 100644 --- a/nova/network/linux_net.py +++ b/nova/network/linux_net.py @@ -44,13 +44,10 @@ flags.DEFINE_string('dhcpbridge_flagfile', flags.DEFINE_string('dhcp_domain', 'novalocal', 'domain to use for building the hostnames') - flags.DEFINE_string('networks_path', '$state_path/networks', 'Location to keep network config files') flags.DEFINE_string('public_interface', 'eth0', 'Interface for public IP addresses') -flags.DEFINE_string('vlan_interface', 'eth0', - 'network device for vlans') flags.DEFINE_string('dhcpbridge', _bin_file('nova-dhcpbridge'), 'location of nova-dhcpbridge') flags.DEFINE_string('routing_source_ip', '$my_ip', diff --git a/nova/network/manager.py b/nova/network/manager.py index d994f7dc8..86ee4fc00 100644 --- a/nova/network/manager.py +++ b/nova/network/manager.py @@ -73,6 +73,8 @@ flags.DEFINE_string('flat_interface', None, flags.DEFINE_string('flat_network_dhcp_start', '10.0.0.2', 'Dhcp start for FlatDhcp') flags.DEFINE_integer('vlan_start', 100, 'First VLAN for private networks') +flags.DEFINE_string('vlan_interface', 'eth0', + 'network device for vlans') flags.DEFINE_integer('num_networks', 1, 'Number of networks to support') flags.DEFINE_string('vpn_ip', '$my_ip', 'Public IP for the cloudpipe VPN servers') diff --git a/nova/network/xenapi_net.py b/nova/network/xenapi_net.py new file mode 100644 index 000000000..9a99602d9 --- /dev/null +++ b/nova/network/xenapi_net.py @@ -0,0 +1,85 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2011 Citrix Systems, Inc. +# Copyright 2011 OpenStack LLC. +# +# 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. + +""" +Implements vlans, bridges, and iptables rules using linux utilities. +""" + +import os + +from nova import db +from nova import exception +from nova import flags +from nova import log as logging +from nova import utils +from nova.virt.xenapi_conn import XenAPISession +from nova.virt.xenapi import network_utils + +LOG = logging.getLogger("nova.xenapi_net") + +FLAGS = flags.FLAGS + + +def ensure_vlan_bridge(vlan_num, bridge, net_attrs=None): + """Create a vlan and bridge unless they already exist.""" + # Open xenapi session + LOG.debug("ENTERING ensure_vlan_bridge in xenapi net") + url = FLAGS.xenapi_connection_url + username = FLAGS.xenapi_connection_username + password = FLAGS.xenapi_connection_password + session = XenAPISession(url, username, password) + # Check whether bridge already exists + # Retrieve network whose name_label is "bridge" + network_ref = network_utils.NetworkHelper.find_network_with_name_label( + session, + bridge) + if network_ref == None: + # If bridge does not exists + # 1 - create network + description = "network for nova bridge %s" % bridge + network_rec = {'name_label': bridge, + 'name_description': description, + 'other_config': {}} + network_ref = session.call_xenapi('network.create', network_rec) + # 2 - find PIF for VLAN + expr = 'field "device" = "%s" and \ + field "VLAN" = "-1"' % FLAGS.vlan_interface + pifs = session.call_xenapi('PIF.get_all_records_where', expr) + pif_ref = None + # Multiple PIF are ok: we are dealing with a pool + if len(pifs) == 0: + raise Exception( + _('Found no PIF for device %s') % FLAGS.vlan_interface) + # 3 - create vlan for network + for pif_ref in pifs.keys(): + session.call_xenapi('VLAN.create', + pif_ref, + str(vlan_num), + network_ref) + else: + # Check VLAN tag is appropriate + network_rec = session.call_xenapi('network.get_record', network_ref) + # Retrieve PIFs from network + for pif_ref in network_rec['PIFs']: + # Retrieve VLAN from PIF + pif_rec = session.call_xenapi('PIF.get_record', pif_ref) + pif_vlan = int(pif_rec['VLAN']) + # Raise an exception if VLAN != vlan_num + if pif_vlan != vlan_num: + raise Exception(_("PIF %(pif_rec['uuid'])s for network " + "%(bridge)s has VLAN id %(pif_vlan)d. " + "Expected %(vlan_num)d") % locals()) diff --git a/nova/tests/api/openstack/test_images.py b/nova/tests/api/openstack/test_images.py index 738bdda19..57e447dce 100644 --- a/nova/tests/api/openstack/test_images.py +++ b/nova/tests/api/openstack/test_images.py @@ -20,11 +20,13 @@ Tests of the new image services, both as a service layer, and as a WSGI layer """ +import copy import json import datetime import os import shutil import tempfile +import xml.dom.minidom as minidom import stubout import webob @@ -214,12 +216,14 @@ class GlanceImageServiceTest(_BaseImageServiceTests): class ImageControllerWithGlanceServiceTest(test.TestCase): - """Test of the OpenStack API /images application controller""" - + """ + Test of the OpenStack API /images application controller w/Glance. + """ NOW_GLANCE_FORMAT = "2010-10-11T10:30:22" NOW_API_FORMAT = "2010-10-11T10:30:22Z" def setUp(self): + """Run before each test.""" super(ImageControllerWithGlanceServiceTest, self).setUp() self.orig_image_service = FLAGS.image_service FLAGS.image_service = 'nova.image.glance.GlanceImageService' @@ -230,18 +234,30 @@ class ImageControllerWithGlanceServiceTest(test.TestCase): fakes.stub_out_rate_limiting(self.stubs) fakes.stub_out_auth(self.stubs) fakes.stub_out_key_pair_funcs(self.stubs) - fixtures = self._make_image_fixtures() - fakes.stub_out_glance(self.stubs, initial_fixtures=fixtures) + self.fixtures = self._make_image_fixtures() + fakes.stub_out_glance(self.stubs, initial_fixtures=self.fixtures) def tearDown(self): + """Run after each test.""" self.stubs.UnsetAll() FLAGS.image_service = self.orig_image_service super(ImageControllerWithGlanceServiceTest, self).tearDown() + def _applicable_fixture(self, fixture, user_id): + """Determine if this fixture is applicable for given user id.""" + is_public = fixture["is_public"] + try: + uid = int(fixture["properties"]["user_id"]) + except KeyError: + uid = None + return uid == user_id or is_public + def test_get_image_index(self): - req = webob.Request.blank('/v1.0/images') - res = req.get_response(fakes.wsgi_app()) - image_metas = json.loads(res.body)['images'] + request = webob.Request.blank('/v1.0/images') + response = request.get_response(fakes.wsgi_app()) + + response_dict = json.loads(response.body) + response_list = response_dict["images"] expected = [{'id': 123, 'name': 'public image'}, {'id': 124, 'name': 'queued backup'}, @@ -249,32 +265,379 @@ class ImageControllerWithGlanceServiceTest(test.TestCase): {'id': 126, 'name': 'active backup'}, {'id': 127, 'name': 'killed backup'}] - self.assertDictListMatch(image_metas, expected) + self.assertDictListMatch(response_list, expected) + + def test_get_image(self): + request = webob.Request.blank('/v1.0/images/123') + response = request.get_response(fakes.wsgi_app()) + + self.assertEqual(200, response.status_int) + + actual_image = json.loads(response.body) + + expected_image = { + "image": { + "id": 123, + "name": "public image", + "updated": self.NOW_API_FORMAT, + "created": self.NOW_API_FORMAT, + "status": "ACTIVE", + }, + } + + self.assertEqual(expected_image, actual_image) + + def test_get_image_v1_1(self): + request = webob.Request.blank('/v1.1/images/123') + response = request.get_response(fakes.wsgi_app()) + + actual_image = json.loads(response.body) + + href = "http://localhost/v1.1/images/123" + + expected_image = { + "image": { + "id": 123, + "name": "public image", + "updated": self.NOW_API_FORMAT, + "created": self.NOW_API_FORMAT, + "status": "ACTIVE", + "links": [{ + "rel": "self", + "href": href, + }, + { + "rel": "bookmark", + "type": "application/json", + "href": href, + }, + { + "rel": "bookmark", + "type": "application/xml", + "href": href, + }], + }, + } + + self.assertEqual(expected_image, actual_image) + + def test_get_image_xml(self): + request = webob.Request.blank('/v1.0/images/123') + request.accept = "application/xml" + response = request.get_response(fakes.wsgi_app()) + + actual_image = minidom.parseString(response.body.replace(" ", "")) + + expected_now = self.NOW_API_FORMAT + expected_image = minidom.parseString(""" + <image id="123" + name="public image" + updated="%(expected_now)s" + created="%(expected_now)s" + status="ACTIVE" /> + """ % (locals())) + + self.assertEqual(expected_image.toxml(), actual_image.toxml()) + + def test_get_image_v1_1_xml(self): + request = webob.Request.blank('/v1.1/images/123') + request.accept = "application/xml" + response = request.get_response(fakes.wsgi_app()) + + actual_image = minidom.parseString(response.body.replace(" ", "")) + + expected_href = "http://localhost/v1.1/images/123" + expected_now = self.NOW_API_FORMAT + expected_image = minidom.parseString(""" + <image id="123" + name="public image" + updated="%(expected_now)s" + created="%(expected_now)s" + status="ACTIVE"> + <links> + <link href="%(expected_href)s" rel="self"/> + <link href="%(expected_href)s" rel="bookmark" + type="application/json" /> + <link href="%(expected_href)s" rel="bookmark" + type="application/xml" /> + </links> + </image> + """.replace(" ", "") % (locals())) + + self.assertEqual(expected_image.toxml(), actual_image.toxml()) + + def test_get_image_404_json(self): + request = webob.Request.blank('/v1.0/images/NonExistantImage') + response = request.get_response(fakes.wsgi_app()) + self.assertEqual(404, response.status_int) + + expected = { + "itemNotFound": { + "message": "Image not found.", + "code": 404, + }, + } + + actual = json.loads(response.body) + + self.assertEqual(expected, actual) + + def test_get_image_404_xml(self): + request = webob.Request.blank('/v1.0/images/NonExistantImage') + request.accept = "application/xml" + response = request.get_response(fakes.wsgi_app()) + self.assertEqual(404, response.status_int) + + expected = minidom.parseString(""" + <itemNotFound code="404"> + <message> + Image not found. + </message> + </itemNotFound> + """.replace(" ", "")) + + actual = minidom.parseString(response.body.replace(" ", "")) + + self.assertEqual(expected.toxml(), actual.toxml()) + + def test_get_image_404_v1_1_json(self): + request = webob.Request.blank('/v1.1/images/NonExistantImage') + response = request.get_response(fakes.wsgi_app()) + self.assertEqual(404, response.status_int) + + expected = { + "itemNotFound": { + "message": "Image not found.", + "code": 404, + }, + } + + actual = json.loads(response.body) + + self.assertEqual(expected, actual) + + def test_get_image_404_v1_1_xml(self): + request = webob.Request.blank('/v1.1/images/NonExistantImage') + request.accept = "application/xml" + response = request.get_response(fakes.wsgi_app()) + self.assertEqual(404, response.status_int) + + expected = minidom.parseString(""" + <itemNotFound code="404"> + <message> + Image not found. + </message> + </itemNotFound> + """.replace(" ", "")) + + actual = minidom.parseString(response.body.replace(" ", "")) + + self.assertEqual(expected.toxml(), actual.toxml()) + + def test_get_image_index_v1_1(self): + request = webob.Request.blank('/v1.1/images') + response = request.get_response(fakes.wsgi_app()) + + response_dict = json.loads(response.body) + response_list = response_dict["images"] + + fixtures = copy.copy(self.fixtures) + + for image in fixtures: + if not self._applicable_fixture(image, 1): + fixtures.remove(image) + continue + + href = "http://localhost/v1.1/images/%s" % image["id"] + test_image = { + "id": image["id"], + "name": image["name"], + "links": [{ + "rel": "self", + "href": "http://localhost/v1.1/images/%s" % image["id"], + }, + { + "rel": "bookmark", + "type": "application/json", + "href": href, + }, + { + "rel": "bookmark", + "type": "application/xml", + "href": href, + }], + } + self.assertTrue(test_image in response_list) + + self.assertEqual(len(response_list), len(fixtures)) def test_get_image_details(self): - req = webob.Request.blank('/v1.0/images/detail') - res = req.get_response(fakes.wsgi_app()) - image_metas = json.loads(res.body)['images'] - - now = self.NOW_API_FORMAT - expected = [ - {'id': 123, 'name': 'public image', 'updated': now, - 'created': now, 'status': 'ACTIVE'}, - {'id': 124, 'name': 'queued backup', 'serverId': 42, - 'updated': now, 'created': now, - 'status': 'QUEUED'}, - {'id': 125, 'name': 'saving backup', 'serverId': 42, - 'updated': now, 'created': now, - 'status': 'SAVING', 'progress': 0}, - {'id': 126, 'name': 'active backup', 'serverId': 42, - 'updated': now, 'created': now, - 'status': 'ACTIVE'}, - {'id': 127, 'name': 'killed backup', 'serverId': 42, - 'updated': now, 'created': now, - 'status': 'FAILED'} - ] - - self.assertDictListMatch(image_metas, expected) + request = webob.Request.blank('/v1.0/images/detail') + response = request.get_response(fakes.wsgi_app()) + + response_dict = json.loads(response.body) + response_list = response_dict["images"] + + expected = [{ + 'id': 123, + 'name': 'public image', + 'updated': self.NOW_API_FORMAT, + 'created': self.NOW_API_FORMAT, + 'status': 'ACTIVE', + }, + { + 'id': 124, + 'name': 'queued backup', + 'serverId': 42, + 'updated': self.NOW_API_FORMAT, + 'created': self.NOW_API_FORMAT, + 'status': 'QUEUED', + }, + { + 'id': 125, + 'name': 'saving backup', + 'serverId': 42, + 'updated': self.NOW_API_FORMAT, + 'created': self.NOW_API_FORMAT, + 'status': 'SAVING', + 'progress': 0, + }, + { + 'id': 126, + 'name': 'active backup', + 'serverId': 42, + 'updated': self.NOW_API_FORMAT, + 'created': self.NOW_API_FORMAT, + 'status': 'ACTIVE' + }, + { + 'id': 127, + 'name': 'killed backup', 'serverId': 42, + 'updated': self.NOW_API_FORMAT, + 'created': self.NOW_API_FORMAT, + 'status': 'FAILED', + }] + + self.assertDictListMatch(expected, response_list) + + def test_get_image_details_v1_1(self): + request = webob.Request.blank('/v1.1/images/detail') + response = request.get_response(fakes.wsgi_app()) + + response_dict = json.loads(response.body) + response_list = response_dict["images"] + + expected = [{ + 'id': 123, + 'name': 'public image', + 'updated': self.NOW_API_FORMAT, + 'created': self.NOW_API_FORMAT, + 'status': 'ACTIVE', + "links": [{ + "rel": "self", + "href": "http://localhost/v1.1/images/123", + }, + { + "rel": "bookmark", + "type": "application/json", + "href": "http://localhost/v1.1/images/123", + }, + { + "rel": "bookmark", + "type": "application/xml", + "href": "http://localhost/v1.1/images/123", + }], + }, + { + 'id': 124, + 'name': 'queued backup', + 'serverId': 42, + 'updated': self.NOW_API_FORMAT, + 'created': self.NOW_API_FORMAT, + 'status': 'QUEUED', + "links": [{ + "rel": "self", + "href": "http://localhost/v1.1/images/124", + }, + { + "rel": "bookmark", + "type": "application/json", + "href": "http://localhost/v1.1/images/124", + }, + { + "rel": "bookmark", + "type": "application/xml", + "href": "http://localhost/v1.1/images/124", + }], + }, + { + 'id': 125, + 'name': 'saving backup', + 'serverId': 42, + 'updated': self.NOW_API_FORMAT, + 'created': self.NOW_API_FORMAT, + 'status': 'SAVING', + 'progress': 0, + "links": [{ + "rel": "self", + "href": "http://localhost/v1.1/images/125", + }, + { + "rel": "bookmark", + "type": "application/json", + "href": "http://localhost/v1.1/images/125", + }, + { + "rel": "bookmark", + "type": "application/xml", + "href": "http://localhost/v1.1/images/125", + }], + }, + { + 'id': 126, + 'name': 'active backup', + 'serverId': 42, + 'updated': self.NOW_API_FORMAT, + 'created': self.NOW_API_FORMAT, + 'status': 'ACTIVE', + "links": [{ + "rel": "self", + "href": "http://localhost/v1.1/images/126", + }, + { + "rel": "bookmark", + "type": "application/json", + "href": "http://localhost/v1.1/images/126", + }, + { + "rel": "bookmark", + "type": "application/xml", + "href": "http://localhost/v1.1/images/126", + }], + }, + { + 'id': 127, + 'name': 'killed backup', 'serverId': 42, + 'updated': self.NOW_API_FORMAT, + 'created': self.NOW_API_FORMAT, + 'status': 'FAILED', + "links": [{ + "rel": "self", + "href": "http://localhost/v1.1/images/127", + }, + { + "rel": "bookmark", + "type": "application/json", + "href": "http://localhost/v1.1/images/127", + }, + { + "rel": "bookmark", + "type": "application/xml", + "href": "http://localhost/v1.1/images/127", + }], + }] + + self.assertDictListMatch(expected, response_list) def test_get_image_found(self): req = webob.Request.blank('/v1.0/images/123') diff --git a/nova/tests/api/openstack/test_shared_ip_groups.py b/nova/tests/api/openstack/test_shared_ip_groups.py index b4de2ef41..c2bd7e45a 100644 --- a/nova/tests/api/openstack/test_shared_ip_groups.py +++ b/nova/tests/api/openstack/test_shared_ip_groups.py @@ -16,25 +16,49 @@ # under the License. import stubout +import webob from nova import test from nova.api.openstack import shared_ip_groups +from nova.tests.api.openstack import fakes class SharedIpGroupsTest(test.TestCase): def setUp(self): super(SharedIpGroupsTest, self).setUp() self.stubs = stubout.StubOutForTesting() + fakes.FakeAuthManager.reset_fake_data() + fakes.FakeAuthDatabase.data = {} + fakes.stub_out_auth(self.stubs) def tearDown(self): self.stubs.UnsetAll() super(SharedIpGroupsTest, self).tearDown() def test_get_shared_ip_groups(self): - pass + req = webob.Request.blank('/v1.0/shared_ip_groups') + res = req.get_response(fakes.wsgi_app()) + self.assertEqual(res.status_int, 501) def test_create_shared_ip_group(self): - pass + req = webob.Request.blank('/v1.0/shared_ip_groups') + req.method = 'POST' + res = req.get_response(fakes.wsgi_app()) + self.assertEqual(res.status_int, 501) + + def test_update_shared_ip_group(self): + req = webob.Request.blank('/v1.0/shared_ip_groups/12') + req.method = 'PUT' + res = req.get_response(fakes.wsgi_app()) + self.assertEqual(res.status_int, 501) def test_delete_shared_ip_group(self): - pass + req = webob.Request.blank('/v1.0/shared_ip_groups/12') + req.method = 'DELETE' + res = req.get_response(fakes.wsgi_app()) + self.assertEqual(res.status_int, 501) + + def test_deprecated_v11(self): + req = webob.Request.blank('/v1.1/shared_ip_groups') + res = req.get_response(fakes.wsgi_app()) + self.assertEqual(res.status_int, 404) diff --git a/nova/tests/api/openstack/test_versions.py b/nova/tests/api/openstack/test_versions.py index ebb59a9a6..2640a4ddb 100644 --- a/nova/tests/api/openstack/test_versions.py +++ b/nova/tests/api/openstack/test_versions.py @@ -34,8 +34,10 @@ class VersionsTest(test.TestCase): def test_get_version_list(self): req = webob.Request.blank('/') + req.accept = "application/json" res = req.get_response(fakes.wsgi_app()) self.assertEqual(res.status_int, 200) + self.assertEqual(res.content_type, "application/json") versions = json.loads(res.body)["versions"] expected = [ { @@ -61,6 +63,30 @@ class VersionsTest(test.TestCase): ] self.assertEqual(versions, expected) + def test_get_version_list_xml(self): + req = webob.Request.blank('/') + req.accept = "application/xml" + res = req.get_response(fakes.wsgi_app()) + self.assertEqual(res.status_int, 200) + self.assertEqual(res.content_type, "application/xml") + + expected = """<versions> + <version id="v1.1" status="CURRENT"> + <links> + <link href="http://localhost/v1.1" rel="self"/> + </links> + </version> + <version id="v1.0" status="DEPRECATED"> + <links> + <link href="http://localhost/v1.0" rel="self"/> + </links> + </version> + </versions>""".replace(" ", "").replace("\n", "") + + actual = res.body.replace(" ", "").replace("\n", "") + + self.assertEqual(expected, actual) + def test_view_builder(self): base_url = "http://example.org/" diff --git a/nova/tests/db/fakes.py b/nova/tests/db/fakes.py index 21a5481bd..7ddfe377a 100644 --- a/nova/tests/db/fakes.py +++ b/nova/tests/db/fakes.py @@ -25,7 +25,7 @@ from nova import utils def stub_out_db_instance_api(stubs, injected=True): - """ Stubs out the db API for creating Instances """ + """Stubs out the db API for creating Instances.""" INSTANCE_TYPES = { 'm1.tiny': dict(memory_mb=512, @@ -56,27 +56,39 @@ def stub_out_db_instance_api(stubs, injected=True): flavorid=5, rxtx_cap=5)} - network_fields = { - 'id': 'test', - 'bridge': 'xenbr0', - 'label': 'test_network', - 'netmask': '255.255.255.0', - 'cidr_v6': 'fe80::a00:0/120', - 'netmask_v6': '120', - 'gateway': '10.0.0.1', - 'gateway_v6': 'fe80::a00:1', - 'broadcast': '10.0.0.255', - 'dns': '10.0.0.2', - 'ra_server': None, - 'injected': injected} - - fixed_ip_fields = { - 'address': '10.0.0.3', - 'address_v6': 'fe80::a00:3', - 'network_id': 'test'} + flat_network_fields = {'id': 'fake_flat', + 'bridge': 'xenbr0', + 'label': 'fake_flat_network', + 'netmask': '255.255.255.0', + 'cidr_v6': 'fe80::a00:0/120', + 'netmask_v6': '120', + 'gateway': '10.0.0.1', + 'gateway_v6': 'fe80::a00:1', + 'broadcast': '10.0.0.255', + 'dns': '10.0.0.2', + 'ra_server': None, + 'injected': injected} + + vlan_network_fields = {'id': 'fake_vlan', + 'bridge': 'br111', + 'label': 'fake_vlan_network', + 'netmask': '255.255.255.0', + 'cidr_v6': 'fe80::a00:0/120', + 'netmask_v6': '120', + 'gateway': '10.0.0.1', + 'gateway_v6': 'fe80::a00:1', + 'broadcast': '10.0.0.255', + 'dns': '10.0.0.2', + 'ra_server': None, + 'vlan': 111, + 'injected': False} + + fixed_ip_fields = {'address': '10.0.0.3', + 'address_v6': 'fe80::a00:3', + 'network_id': 'fake_flat'} class FakeModel(object): - """ Stubs out for model """ + """Stubs out for model.""" def __init__(self, values): self.values = values @@ -96,10 +108,19 @@ def stub_out_db_instance_api(stubs, injected=True): return INSTANCE_TYPES[name] def fake_network_get_by_instance(context, instance_id): + # Even instance numbers are on vlan networks + if instance_id % 2 == 0: + return FakeModel(vlan_network_fields) + else: + return FakeModel(flat_network_fields) return FakeModel(network_fields) def fake_network_get_all_by_instance(context, instance_id): - return [FakeModel(network_fields)] + # Even instance numbers are on vlan networks + if instance_id % 2 == 0: + return [FakeModel(vlan_network_fields)] + else: + return [FakeModel(flat_network_fields)] def fake_instance_get_fixed_address(context, instance_id): return FakeModel(fixed_ip_fields).address @@ -111,6 +132,8 @@ def stub_out_db_instance_api(stubs, injected=True): return [FakeModel(fixed_ip_fields)] stubs.Set(db, 'network_get_by_instance', fake_network_get_by_instance) + stubs.Set(db, 'network_get_all_by_instance', + fake_network_get_all_by_instance) stubs.Set(db, 'instance_type_get_all', fake_instance_type_get_all) stubs.Set(db, 'instance_type_get_by_name', fake_instance_type_get_by_name) stubs.Set(db, 'instance_get_fixed_address', diff --git a/nova/tests/fake_utils.py b/nova/tests/fake_utils.py index 823c775cb..be59970c9 100644 --- a/nova/tests/fake_utils.py +++ b/nova/tests/fake_utils.py @@ -14,8 +14,7 @@ # License for the specific language governing permissions and limitations # under the License. -"""This modules stubs out functions in nova.utils -""" +"""This modules stubs out functions in nova.utils.""" import re import types @@ -42,21 +41,25 @@ def fake_execute_clear_log(): def fake_execute_set_repliers(repliers): - """Allows the client to configure replies to commands""" + """Allows the client to configure replies to commands.""" global _fake_execute_repliers _fake_execute_repliers = repliers def fake_execute_default_reply_handler(*ignore_args, **ignore_kwargs): - """A reply handler for commands that haven't been added to the reply - list. Returns empty strings for stdout and stderr + """A reply handler for commands that haven't been added to the reply list. + + Returns empty strings for stdout and stderr. + """ return '', '' def fake_execute(*cmd_parts, **kwargs): - """This function stubs out execute, optionally executing - a preconfigued function to return expected data + """This function stubs out execute. + + It optionally executes a preconfigued function to return expected data. + """ global _fake_execute_repliers diff --git a/nova/tests/image/test_glance.py b/nova/tests/image/test_glance.py index d03aa9cc8..9d0b14613 100644 --- a/nova/tests/image/test_glance.py +++ b/nova/tests/image/test_glance.py @@ -55,7 +55,8 @@ class NullWriter(object): class BaseGlanceTest(unittest.TestCase): - NOW_GLANCE_FORMAT = "2010-10-11T10:30:22" + NOW_GLANCE_OLD_FORMAT = "2010-10-11T10:30:22" + NOW_GLANCE_FORMAT = "2010-10-11T10:30:22.000000" NOW_DATETIME = datetime.datetime(2010, 10, 11, 10, 30, 22) def setUp(self): @@ -74,6 +75,10 @@ class BaseGlanceTest(unittest.TestCase): self.assertEqual(image_meta['updated_at'], None) self.assertEqual(image_meta['deleted_at'], None) + def assertDateTimesBlank(self, image_meta): + self.assertEqual(image_meta['updated_at'], '') + self.assertEqual(image_meta['deleted_at'], '') + class TestGlanceImageServiceProperties(BaseGlanceTest): def test_show_passes_through_to_client(self): @@ -108,38 +113,72 @@ class TestGetterDateTimeNoneTests(BaseGlanceTest): image_meta = self.service.show(self.context, 'image1') self.assertDateTimesEmpty(image_meta) + def test_show_handles_blank_datetimes(self): + self.client.images = self._make_blank_datetime_fixtures() + image_meta = self.service.show(self.context, 'image1') + self.assertDateTimesBlank(image_meta) + def test_detail_handles_none_datetimes(self): self.client.images = self._make_none_datetime_fixtures() image_meta = self.service.detail(self.context)[0] self.assertDateTimesEmpty(image_meta) + def test_detail_handles_blank_datetimes(self): + self.client.images = self._make_blank_datetime_fixtures() + image_meta = self.service.detail(self.context)[0] + self.assertDateTimesBlank(image_meta) + def test_get_handles_none_datetimes(self): self.client.images = self._make_none_datetime_fixtures() writer = NullWriter() image_meta = self.service.get(self.context, 'image1', writer) self.assertDateTimesEmpty(image_meta) + def test_get_handles_blank_datetimes(self): + self.client.images = self._make_blank_datetime_fixtures() + writer = NullWriter() + image_meta = self.service.get(self.context, 'image1', writer) + self.assertDateTimesBlank(image_meta) + def test_show_makes_datetimes(self): self.client.images = self._make_datetime_fixtures() image_meta = self.service.show(self.context, 'image1') self.assertDateTimesFilled(image_meta) + image_meta = self.service.show(self.context, 'image2') + self.assertDateTimesFilled(image_meta) def test_detail_makes_datetimes(self): self.client.images = self._make_datetime_fixtures() image_meta = self.service.detail(self.context)[0] self.assertDateTimesFilled(image_meta) + image_meta = self.service.detail(self.context)[1] + self.assertDateTimesFilled(image_meta) def test_get_makes_datetimes(self): self.client.images = self._make_datetime_fixtures() writer = NullWriter() image_meta = self.service.get(self.context, 'image1', writer) self.assertDateTimesFilled(image_meta) + image_meta = self.service.get(self.context, 'image2', writer) + self.assertDateTimesFilled(image_meta) def _make_datetime_fixtures(self): - fixtures = {'image1': {'name': 'image1', 'is_public': True, - 'created_at': self.NOW_GLANCE_FORMAT, - 'updated_at': self.NOW_GLANCE_FORMAT, - 'deleted_at': self.NOW_GLANCE_FORMAT}} + fixtures = { + 'image1': { + 'name': 'image1', + 'is_public': True, + 'created_at': self.NOW_GLANCE_FORMAT, + 'updated_at': self.NOW_GLANCE_FORMAT, + 'deleted_at': self.NOW_GLANCE_FORMAT, + }, + 'image2': { + 'name': 'image2', + 'is_public': True, + 'created_at': self.NOW_GLANCE_OLD_FORMAT, + 'updated_at': self.NOW_GLANCE_OLD_FORMAT, + 'deleted_at': self.NOW_GLANCE_OLD_FORMAT, + }, + } return fixtures def _make_none_datetime_fixtures(self): @@ -148,6 +187,12 @@ class TestGetterDateTimeNoneTests(BaseGlanceTest): 'deleted_at': None}} return fixtures + def _make_blank_datetime_fixtures(self): + fixtures = {'image1': {'name': 'image1', 'is_public': True, + 'updated_at': '', + 'deleted_at': ''}} + return fixtures + class TestMutatorDateTimeTests(BaseGlanceTest): """Tests create(), update()""" diff --git a/nova/tests/integrated/api/client.py b/nova/tests/integrated/api/client.py index fc7c344e7..7e20c9b00 100644 --- a/nova/tests/integrated/api/client.py +++ b/nova/tests/integrated/api/client.py @@ -56,8 +56,12 @@ class OpenStackApiNotFoundException(OpenStackApiException): class TestOpenStackClient(object): - """ A really basic OpenStack API client that is under our control, - so we can make changes / insert hooks for testing""" + """Simple OpenStack API Client. + + This is a really basic OpenStack API client that is under our control, + so we can make changes / insert hooks for testing + + """ def __init__(self, auth_user, auth_key, auth_uri): super(TestOpenStackClient, self).__init__() @@ -90,6 +94,7 @@ class TestOpenStackClient(object): LOG.info(_("Doing %(method)s on %(relative_url)s") % locals()) if body: LOG.info(_("Body: %s") % body) + headers.setdefault('Content-Type', 'application/json') conn.request(method, relative_url, body, headers) response = conn.getresponse() @@ -121,7 +126,7 @@ class TestOpenStackClient(object): def api_request(self, relative_uri, check_response_status=None, **kwargs): auth_result = self._authenticate() - #NOTE(justinsb): httplib 'helpfully' converts headers to lower case + # NOTE(justinsb): httplib 'helpfully' converts headers to lower case base_uri = auth_result['x-server-management-url'] full_uri = base_uri + relative_uri @@ -208,3 +213,32 @@ class TestOpenStackClient(object): def delete_flavor(self, flavor_id): return self.api_delete('/flavors/%s' % flavor_id) + + def get_volume(self, volume_id): + return self.api_get('/volumes/%s' % volume_id)['volume'] + + def get_volumes(self, detail=True): + rel_url = '/volumes/detail' if detail else '/volumes' + return self.api_get(rel_url)['volumes'] + + def post_volume(self, volume): + return self.api_post('/volumes', volume)['volume'] + + def delete_volume(self, volume_id): + return self.api_delete('/volumes/%s' % volume_id) + + def get_server_volume(self, server_id, attachment_id): + return self.api_get('/servers/%s/volume_attachments/%s' % + (server_id, attachment_id))['volumeAttachment'] + + def get_server_volumes(self, server_id): + return self.api_get('/servers/%s/volume_attachments' % + (server_id))['volumeAttachments'] + + def post_server_volume(self, server_id, volume_attachment): + return self.api_post('/servers/%s/volume_attachments' % + (server_id), volume_attachment)['volumeAttachment'] + + def delete_server_volume(self, server_id, attachment_id): + return self.api_delete('/servers/%s/volume_attachments/%s' % + (server_id, attachment_id)) diff --git a/nova/tests/integrated/integrated_helpers.py b/nova/tests/integrated/integrated_helpers.py index cc7326e73..2e5d67017 100644 --- a/nova/tests/integrated/integrated_helpers.py +++ b/nova/tests/integrated/integrated_helpers.py @@ -27,7 +27,6 @@ from nova import flags from nova import service from nova import test # For the flags from nova.auth import manager -from nova.exception import Error from nova.log import logging from nova.tests.integrated.api import client @@ -38,19 +37,19 @@ LOG = logging.getLogger('nova.tests.integrated') def generate_random_alphanumeric(length): - """Creates a random alphanumeric string of specified length""" + """Creates a random alphanumeric string of specified length.""" return ''.join(random.choice(string.ascii_uppercase + string.digits) for _x in range(length)) def generate_random_numeric(length): - """Creates a random numeric string of specified length""" + """Creates a random numeric string of specified length.""" return ''.join(random.choice(string.digits) for _x in range(length)) def generate_new_element(items, prefix, numeric=False): - """Creates a random string with prefix, that is not in 'items' list""" + """Creates a random string with prefix, that is not in 'items' list.""" while True: if numeric: candidate = prefix + generate_random_numeric(8) @@ -58,7 +57,7 @@ def generate_new_element(items, prefix, numeric=False): candidate = prefix + generate_random_alphanumeric(8) if not candidate in items: return candidate - print "Random collision on %s" % candidate + LOG.debug("Random collision on %s" % candidate) class TestUser(object): @@ -73,23 +72,41 @@ class TestUser(object): self.secret, self.auth_url) + def get_unused_server_name(self): + servers = self.openstack_api.get_servers() + server_names = [server['name'] for server in servers] + return generate_new_element(server_names, 'server') + + def get_invalid_image(self): + images = self.openstack_api.get_images() + image_ids = [image['id'] for image in images] + return generate_new_element(image_ids, '', numeric=True) + + def get_valid_image(self, create=False): + images = self.openstack_api.get_images() + if create and not images: + # TODO(justinsb): No way currently to create an image through API + #created_image = self.openstack_api.post_image(image) + #images.append(created_image) + raise exception.Error("No way to create an image through API") + + if images: + return images[0] + return None + class IntegratedUnitTestContext(object): - def __init__(self): + def __init__(self, auth_url): self.auth_manager = manager.AuthManager() - self.wsgi_server = None - self.wsgi_apps = [] - self.api_service = None - - self.services = [] - self.auth_url = None + self.auth_url = auth_url self.project_name = None + self.test_user = None + self.setup() def setup(self): - self._start_services() self._create_test_user() def _create_test_user(self): @@ -99,12 +116,6 @@ class IntegratedUnitTestContext(object): self.project_name = 'openstack' self._configure_project(self.project_name, self.test_user) - def _start_services(self): - # WSGI shutdown broken :-( - # bug731668 - if not self.api_service: - self._start_api_service() - def cleanup(self): self.test_user = None @@ -132,6 +143,30 @@ class IntegratedUnitTestContext(object): else: self.auth_manager.add_to_project(user.name, project_name) + +class _IntegratedTestBase(test.TestCase): + def setUp(self): + super(_IntegratedTestBase, self).setUp() + + f = self._get_flags() + self.flags(**f) + + # set up services + self.start_service('compute') + self.start_service('volume') + # NOTE(justinsb): There's a bug here which is eluding me... + # If we start the network_service, all is good, but then subsequent + # tests fail: CloudTestCase.test_ajax_console in particular. + #self.start_service('network') + self.start_service('scheduler') + + self.auth_url = self._start_api_service() + + self.context = IntegratedUnitTestContext(self.auth_url) + + self.user = self.context.test_user + self.api = self.user.openstack_api + def _start_api_service(self): api_service = service.ApiService.create() api_service.start() @@ -139,8 +174,48 @@ class IntegratedUnitTestContext(object): if not api_service: raise Exception("API Service was None") - self.api_service = api_service + auth_url = 'http://localhost:8774/v1.1' + return auth_url + + def tearDown(self): + self.context.cleanup() + super(_IntegratedTestBase, self).tearDown() + + def _get_flags(self): + """An opportunity to setup flags, before the services are started.""" + f = {} + f['image_service'] = 'nova.image.fake.FakeImageService' + f['fake_network'] = True + return f + + def _build_minimal_create_server_request(self): + server = {} + + image = self.user.get_valid_image(create=True) + LOG.debug("Image: %s" % image) + + if 'imageRef' in image: + image_ref = image['imageRef'] + else: + # NOTE(justinsb): The imageRef code hasn't yet landed + LOG.warning("imageRef not yet in images output") + image_ref = image['id'] + + # TODO(justinsb): This is FUBAR + image_ref = abs(hash(image_ref)) + + image_ref = 'http://fake.server/%s' % image_ref + + # We now have a valid imageId + server['imageRef'] = image_ref + + # Set a valid flavorId + flavor = self.api.get_flavors()[0] + LOG.debug("Using flavor: %s" % flavor) + server['flavorRef'] = 'http://fake.server/%s' % flavor['id'] - self.auth_url = 'http://localhost:8774/v1.0' + # Set a valid server name + server_name = self.user.get_unused_server_name() + server['name'] = server_name - return api_service + return server diff --git a/nova/tests/integrated/test_extensions.py b/nova/tests/integrated/test_extensions.py new file mode 100644 index 000000000..0d4ee8cab --- /dev/null +++ b/nova/tests/integrated/test_extensions.py @@ -0,0 +1,44 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 Justin Santa Barbara +# 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 os + +from nova import flags +from nova.log import logging +from nova.tests.integrated import integrated_helpers + + +LOG = logging.getLogger('nova.tests.integrated') + + +FLAGS = flags.FLAGS +FLAGS.verbose = True + + +class ExtensionsTest(integrated_helpers._IntegratedTestBase): + def _get_flags(self): + f = super(ExtensionsTest, self)._get_flags() + f['osapi_extensions_path'] = os.path.join(os.path.dirname(__file__), + "../api/openstack/extensions") + return f + + def test_get_foxnsocks(self): + """Simple check that fox-n-socks works.""" + response = self.api.api_request('/foxnsocks') + foxnsocks = response.read() + LOG.debug("foxnsocks: %s" % foxnsocks) + self.assertEqual('Try to say this Mr. Knox, sir...', foxnsocks) diff --git a/nova/tests/integrated/test_login.py b/nova/tests/integrated/test_login.py index 6b241f240..a5180b6bc 100644 --- a/nova/tests/integrated/test_login.py +++ b/nova/tests/integrated/test_login.py @@ -18,7 +18,6 @@ import unittest from nova import flags -from nova import test from nova.log import logging from nova.tests.integrated import integrated_helpers from nova.tests.integrated.api import client @@ -30,25 +29,15 @@ FLAGS = flags.FLAGS FLAGS.verbose = True -class LoginTest(test.TestCase): - def setUp(self): - super(LoginTest, self).setUp() - self.context = integrated_helpers.IntegratedUnitTestContext() - self.user = self.context.test_user - self.api = self.user.openstack_api - - def tearDown(self): - self.context.cleanup() - super(LoginTest, self).tearDown() - +class LoginTest(integrated_helpers._IntegratedTestBase): def test_login(self): - """Simple check - we list flavors - so we know we're logged in""" + """Simple check - we list flavors - so we know we're logged in.""" flavors = self.api.get_flavors() for flavor in flavors: LOG.debug(_("flavor: %s") % flavor) def test_bad_login_password(self): - """Test that I get a 401 with a bad username""" + """Test that I get a 401 with a bad username.""" bad_credentials_api = client.TestOpenStackClient(self.user.name, "notso_password", self.user.auth_url) @@ -57,7 +46,7 @@ class LoginTest(test.TestCase): bad_credentials_api.get_flavors) def test_bad_login_username(self): - """Test that I get a 401 with a bad password""" + """Test that I get a 401 with a bad password.""" bad_credentials_api = client.TestOpenStackClient("notso_username", self.user.secret, self.user.auth_url) @@ -66,7 +55,7 @@ class LoginTest(test.TestCase): bad_credentials_api.get_flavors) def test_bad_login_both_bad(self): - """Test that I get a 401 with both bad username and bad password""" + """Test that I get a 401 with both bad username and bad password.""" bad_credentials_api = client.TestOpenStackClient("notso_username", "notso_password", self.user.auth_url) diff --git a/nova/tests/integrated/test_servers.py b/nova/tests/integrated/test_servers.py new file mode 100644 index 000000000..749ea8955 --- /dev/null +++ b/nova/tests/integrated/test_servers.py @@ -0,0 +1,184 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 Justin Santa Barbara +# 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 time +import unittest + +from nova import flags +from nova.log import logging +from nova.tests.integrated import integrated_helpers +from nova.tests.integrated.api import client + + +LOG = logging.getLogger('nova.tests.integrated') + + +FLAGS = flags.FLAGS +FLAGS.verbose = True + + +class ServersTest(integrated_helpers._IntegratedTestBase): + def test_get_servers(self): + """Simple check that listing servers works.""" + servers = self.api.get_servers() + for server in servers: + LOG.debug("server: %s" % server) + + def test_create_and_delete_server(self): + """Creates and deletes a server.""" + + # Create server + + # Build the server data gradually, checking errors along the way + server = {} + good_server = self._build_minimal_create_server_request() + + post = {'server': server} + + # Without an imageRef, this throws 500. + # TODO(justinsb): Check whatever the spec says should be thrown here + self.assertRaises(client.OpenStackApiException, + self.api.post_server, post) + + # With an invalid imageRef, this throws 500. + server['imageRef'] = self.user.get_invalid_image() + # TODO(justinsb): Check whatever the spec says should be thrown here + self.assertRaises(client.OpenStackApiException, + self.api.post_server, post) + + # Add a valid imageId/imageRef + server['imageId'] = good_server.get('imageId') + server['imageRef'] = good_server.get('imageRef') + + # Without flavorId, this throws 500 + # TODO(justinsb): Check whatever the spec says should be thrown here + self.assertRaises(client.OpenStackApiException, + self.api.post_server, post) + + # Set a valid flavorId/flavorRef + server['flavorRef'] = good_server.get('flavorRef') + server['flavorId'] = good_server.get('flavorId') + + # Without a name, this throws 500 + # TODO(justinsb): Check whatever the spec says should be thrown here + self.assertRaises(client.OpenStackApiException, + self.api.post_server, post) + + # Set a valid server name + server['name'] = good_server['name'] + + created_server = self.api.post_server(post) + LOG.debug("created_server: %s" % created_server) + self.assertTrue(created_server['id']) + created_server_id = created_server['id'] + + # Check it's there + found_server = self.api.get_server(created_server_id) + self.assertEqual(created_server_id, found_server['id']) + + # It should also be in the all-servers list + servers = self.api.get_servers() + server_ids = [server['id'] for server in servers] + self.assertTrue(created_server_id in server_ids) + + # Wait (briefly) for creation + retries = 0 + while found_server['status'] == 'build': + LOG.debug("found server: %s" % found_server) + time.sleep(1) + found_server = self.api.get_server(created_server_id) + retries = retries + 1 + if retries > 5: + break + + # It should be available... + # TODO(justinsb): Mock doesn't yet do this... + #self.assertEqual('available', found_server['status']) + + self._delete_server(created_server_id) + + def _delete_server(self, server_id): + # Delete the server + self.api.delete_server(server_id) + + # Wait (briefly) for deletion + for _retries in range(5): + try: + found_server = self.api.get_server(server_id) + except client.OpenStackApiNotFoundException: + found_server = None + LOG.debug("Got 404, proceeding") + break + + LOG.debug("Found_server=%s" % found_server) + + # TODO(justinsb): Mock doesn't yet do accurate state changes + #if found_server['status'] != 'deleting': + # break + time.sleep(1) + + # Should be gone + self.assertFalse(found_server) + +# TODO(justinsb): Enable this unit test when the metadata bug is fixed +# def test_create_server_with_metadata(self): +# """Creates a server with metadata""" +# +# # Build the server data gradually, checking errors along the way +# server = self._build_minimal_create_server_request() +# +# for metadata_count in range(30): +# metadata = {} +# for i in range(metadata_count): +# metadata['key_%s' % i] = 'value_%s' % i +# server['metadata'] = metadata +# +# post = {'server': server} +# created_server = self.api.post_server(post) +# LOG.debug("created_server: %s" % created_server) +# self.assertTrue(created_server['id']) +# created_server_id = created_server['id'] +# # Reenable when bug fixed +# # self.assertEqual(metadata, created_server.get('metadata')) +# +# # Check it's there +# found_server = self.api.get_server(created_server_id) +# self.assertEqual(created_server_id, found_server['id']) +# self.assertEqual(metadata, found_server.get('metadata')) +# +# # The server should also be in the all-servers details list +# servers = self.api.get_servers(detail=True) +# server_map = dict((server['id'], server) for server in servers) +# found_server = server_map.get(created_server_id) +# self.assertTrue(found_server) +# # Details do include metadata +# self.assertEqual(metadata, found_server.get('metadata')) +# +# # The server should also be in the all-servers summary list +# servers = self.api.get_servers(detail=False) +# server_map = dict((server['id'], server) for server in servers) +# found_server = server_map.get(created_server_id) +# self.assertTrue(found_server) +# # Summary should not include metadata +# self.assertFalse(found_server.get('metadata')) +# +# # Cleanup +# self._delete_server(created_server_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/nova/tests/integrated/test_volumes.py b/nova/tests/integrated/test_volumes.py new file mode 100644 index 000000000..e9fb3c4d1 --- /dev/null +++ b/nova/tests/integrated/test_volumes.py @@ -0,0 +1,295 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 Justin Santa Barbara +# 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 unittest +import time + +from nova import flags +from nova.log import logging +from nova.tests.integrated import integrated_helpers +from nova.tests.integrated.api import client +from nova.volume import driver + + +LOG = logging.getLogger('nova.tests.integrated') + + +FLAGS = flags.FLAGS +FLAGS.verbose = True + + +class VolumesTest(integrated_helpers._IntegratedTestBase): + def setUp(self): + super(VolumesTest, self).setUp() + driver.LoggingVolumeDriver.clear_logs() + + def _get_flags(self): + f = super(VolumesTest, self)._get_flags() + f['use_local_volumes'] = False # Avoids calling local_path + f['volume_driver'] = 'nova.volume.driver.LoggingVolumeDriver' + return f + + def test_get_volumes_summary(self): + """Simple check that listing volumes works.""" + volumes = self.api.get_volumes(False) + for volume in volumes: + LOG.debug("volume: %s" % volume) + + def test_get_volumes(self): + """Simple check that listing volumes works.""" + volumes = self.api.get_volumes() + for volume in volumes: + LOG.debug("volume: %s" % volume) + + def _poll_while(self, volume_id, continue_states, max_retries=5): + """Poll (briefly) while the state is in continue_states.""" + retries = 0 + while True: + try: + found_volume = self.api.get_volume(volume_id) + except client.OpenStackApiNotFoundException: + found_volume = None + LOG.debug("Got 404, proceeding") + break + + LOG.debug("Found %s" % found_volume) + + self.assertEqual(volume_id, found_volume['id']) + + if not found_volume['status'] in continue_states: + break + + time.sleep(1) + retries = retries + 1 + if retries > max_retries: + break + return found_volume + + def test_create_and_delete_volume(self): + """Creates and deletes a volume.""" + + # Create volume + created_volume = self.api.post_volume({'volume': {'size': 1}}) + LOG.debug("created_volume: %s" % created_volume) + self.assertTrue(created_volume['id']) + created_volume_id = created_volume['id'] + + # Check it's there + found_volume = self.api.get_volume(created_volume_id) + self.assertEqual(created_volume_id, found_volume['id']) + + # It should also be in the all-volume list + volumes = self.api.get_volumes() + volume_names = [volume['id'] for volume in volumes] + self.assertTrue(created_volume_id in volume_names) + + # Wait (briefly) for creation. Delay is due to the 'message queue' + found_volume = self._poll_while(created_volume_id, ['creating']) + + # It should be available... + self.assertEqual('available', found_volume['status']) + + # Delete the volume + self.api.delete_volume(created_volume_id) + + # Wait (briefly) for deletion. Delay is due to the 'message queue' + found_volume = self._poll_while(created_volume_id, ['deleting']) + + # Should be gone + self.assertFalse(found_volume) + + LOG.debug("Logs: %s" % driver.LoggingVolumeDriver.all_logs()) + + create_actions = driver.LoggingVolumeDriver.logs_like( + 'create_volume', + id=created_volume_id) + LOG.debug("Create_Actions: %s" % create_actions) + + self.assertEquals(1, len(create_actions)) + create_action = create_actions[0] + self.assertEquals(create_action['id'], created_volume_id) + self.assertEquals(create_action['availability_zone'], 'nova') + self.assertEquals(create_action['size'], 1) + + export_actions = driver.LoggingVolumeDriver.logs_like( + 'create_export', + id=created_volume_id) + self.assertEquals(1, len(export_actions)) + export_action = export_actions[0] + self.assertEquals(export_action['id'], created_volume_id) + self.assertEquals(export_action['availability_zone'], 'nova') + + delete_actions = driver.LoggingVolumeDriver.logs_like( + 'delete_volume', + id=created_volume_id) + self.assertEquals(1, len(delete_actions)) + delete_action = export_actions[0] + self.assertEquals(delete_action['id'], created_volume_id) + + def test_attach_and_detach_volume(self): + """Creates, attaches, detaches and deletes a volume.""" + + # Create server + server_req = {'server': self._build_minimal_create_server_request()} + # NOTE(justinsb): Create an extra server so that server_id != volume_id + self.api.post_server(server_req) + created_server = self.api.post_server(server_req) + LOG.debug("created_server: %s" % created_server) + server_id = created_server['id'] + + # Create volume + created_volume = self.api.post_volume({'volume': {'size': 1}}) + LOG.debug("created_volume: %s" % created_volume) + volume_id = created_volume['id'] + self._poll_while(volume_id, ['creating']) + + # Check we've got different IDs + self.assertNotEqual(server_id, volume_id) + + # List current server attachments - should be none + attachments = self.api.get_server_volumes(server_id) + self.assertEquals([], attachments) + + # Template attach request + device = '/dev/sdc' + attach_req = {'device': device} + post_req = {'volumeAttachment': attach_req} + + # Try to attach to a non-existent volume; should fail + attach_req['volumeId'] = 3405691582 + self.assertRaises(client.OpenStackApiNotFoundException, + self.api.post_server_volume, server_id, post_req) + + # Try to attach to a non-existent server; should fail + attach_req['volumeId'] = volume_id + self.assertRaises(client.OpenStackApiNotFoundException, + self.api.post_server_volume, 3405691582, post_req) + + # Should still be no attachments... + attachments = self.api.get_server_volumes(server_id) + self.assertEquals([], attachments) + + # Do a real attach + attach_req['volumeId'] = volume_id + attach_result = self.api.post_server_volume(server_id, post_req) + LOG.debug(_("Attachment = %s") % attach_result) + + attachment_id = attach_result['id'] + self.assertEquals(volume_id, attach_result['volumeId']) + + # These fields aren't set because it's async + #self.assertEquals(server_id, attach_result['serverId']) + #self.assertEquals(device, attach_result['device']) + + # This is just an implementation detail, but let's check it... + self.assertEquals(volume_id, attachment_id) + + # NOTE(justinsb): There's an issue with the attach code, in that + # it's currently asynchronous and not recorded until the attach + # completes. So the caller must be 'smart', like this... + attach_done = None + retries = 0 + while True: + try: + attach_done = self.api.get_server_volume(server_id, + attachment_id) + break + except client.OpenStackApiNotFoundException: + LOG.debug("Got 404, waiting") + + time.sleep(1) + retries = retries + 1 + if retries > 10: + break + + expect_attach = {} + expect_attach['id'] = volume_id + expect_attach['volumeId'] = volume_id + expect_attach['serverId'] = server_id + expect_attach['device'] = device + + self.assertEqual(expect_attach, attach_done) + + # Should be one attachemnt + attachments = self.api.get_server_volumes(server_id) + self.assertEquals([expect_attach], attachments) + + # Should be able to get details + attachment_info = self.api.get_server_volume(server_id, attachment_id) + self.assertEquals(expect_attach, attachment_info) + + # Getting details on a different id should fail + self.assertRaises(client.OpenStackApiNotFoundException, + self.api.get_server_volume, server_id, 3405691582) + self.assertRaises(client.OpenStackApiNotFoundException, + self.api.get_server_volume, + 3405691582, attachment_id) + + # Trying to detach a different id should fail + self.assertRaises(client.OpenStackApiNotFoundException, + self.api.delete_server_volume, server_id, 3405691582) + + # Detach should work + self.api.delete_server_volume(server_id, attachment_id) + + # Again, it's async, so wait... + retries = 0 + while True: + try: + attachment = self.api.get_server_volume(server_id, + attachment_id) + LOG.debug("Attachment still there: %s" % attachment) + except client.OpenStackApiNotFoundException: + LOG.debug("Got 404, delete done") + break + + time.sleep(1) + retries = retries + 1 + self.assertTrue(retries < 10) + + # Should be no attachments again + attachments = self.api.get_server_volumes(server_id) + self.assertEquals([], attachments) + + LOG.debug("Logs: %s" % driver.LoggingVolumeDriver.all_logs()) + + # Discover_volume and undiscover_volume are called from compute + # on attach/detach + + disco_moves = driver.LoggingVolumeDriver.logs_like( + 'discover_volume', + id=volume_id) + LOG.debug("discover_volume actions: %s" % disco_moves) + + self.assertEquals(1, len(disco_moves)) + disco_move = disco_moves[0] + self.assertEquals(disco_move['id'], volume_id) + + last_days_of_disco_moves = driver.LoggingVolumeDriver.logs_like( + 'undiscover_volume', + id=volume_id) + LOG.debug("undiscover_volume actions: %s" % last_days_of_disco_moves) + + self.assertEquals(1, len(last_days_of_disco_moves)) + undisco_move = last_days_of_disco_moves[0] + self.assertEquals(undisco_move['id'], volume_id) + self.assertEquals(undisco_move['mountpoint'], device) + self.assertEquals(undisco_move['instance_id'], server_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/nova/tests/test_compute.py b/nova/tests/test_compute.py index d1ef68de4..1b0f426d2 100644 --- a/nova/tests/test_compute.py +++ b/nova/tests/test_compute.py @@ -286,6 +286,16 @@ class ComputeTestCase(test.TestCase): console = self.compute.get_ajax_console(self.context, instance_id) + self.assert_(set(['token', 'host', 'port']).issubset(console.keys())) + self.compute.terminate_instance(self.context, instance_id) + + def test_vnc_console(self): + """Make sure we can a vnc console for an instance.""" + instance_id = self._create_instance() + self.compute.run_instance(self.context, instance_id) + + console = self.compute.get_vnc_console(self.context, + instance_id) self.assert_(console) self.compute.terminate_instance(self.context, instance_id) diff --git a/nova/tests/test_virt.py b/nova/tests/test_virt.py index 3a03159ff..958c8e3e2 100644 --- a/nova/tests/test_virt.py +++ b/nova/tests/test_virt.py @@ -225,6 +225,49 @@ class LibvirtConnTestCase(test.TestCase): self._check_xml_and_uri(instance_data, expect_kernel=True, expect_ramdisk=True, rescue=True) + def test_lxc_container_and_uri(self): + instance_data = dict(self.test_instance) + self._check_xml_and_container(instance_data) + + def _check_xml_and_container(self, instance): + user_context = context.RequestContext(project=self.project, + user=self.user) + instance_ref = db.instance_create(user_context, instance) + host = self.network.get_network_host(user_context.elevated()) + network_ref = db.project_get_network(context.get_admin_context(), + self.project.id) + + fixed_ip = {'address': self.test_ip, + 'network_id': network_ref['id']} + + ctxt = context.get_admin_context() + fixed_ip_ref = db.fixed_ip_create(ctxt, fixed_ip) + db.fixed_ip_update(ctxt, self.test_ip, + {'allocated': True, + 'instance_id': instance_ref['id']}) + + self.flags(libvirt_type='lxc') + conn = libvirt_conn.LibvirtConnection(True) + + uri = conn.get_uri() + self.assertEquals(uri, 'lxc:///') + + xml = conn.to_xml(instance_ref) + tree = xml_to_tree(xml) + + check = [ + (lambda t: t.find('.').get('type'), 'lxc'), + (lambda t: t.find('./os/type').text, 'exe'), + (lambda t: t.find('./devices/filesystem/target').get('dir'), '/')] + + for i, (check, expected_result) in enumerate(check): + self.assertEqual(check(tree), + expected_result, + '%s failed common check %d' % (xml, i)) + + target = tree.find('./devices/filesystem/source').get('dir') + self.assertTrue(len(target) > 0) + def _check_xml_and_uri(self, instance, expect_ramdisk, expect_kernel, rescue=False): user_context = context.RequestContext(project=self.project, diff --git a/nova/tests/test_xenapi.py b/nova/tests/test_xenapi.py index 36c88b020..17e3f55e9 100644 --- a/nova/tests/test_xenapi.py +++ b/nova/tests/test_xenapi.py @@ -14,9 +14,7 @@ # License for the specific language governing permissions and limitations # under the License. -""" -Test suite for XenAPI -""" +"""Test suite for XenAPI.""" import functools import os @@ -65,9 +63,7 @@ def stub_vm_utils_with_vdi_attached_here(function, should_return=True): class XenAPIVolumeTestCase(test.TestCase): - """ - Unit tests for Volume operations - """ + """Unit tests for Volume operations.""" def setUp(self): super(XenAPIVolumeTestCase, self).setUp() self.stubs = stubout.StubOutForTesting() @@ -101,7 +97,7 @@ class XenAPIVolumeTestCase(test.TestCase): return db.volume_create(self.context, vol) def test_create_iscsi_storage(self): - """ This shows how to test helper classes' methods """ + """This shows how to test helper classes' methods.""" stubs.stubout_session(self.stubs, stubs.FakeSessionForVolumeTests) session = xenapi_conn.XenAPISession('test_url', 'root', 'test_pass') helper = volume_utils.VolumeHelper @@ -116,7 +112,7 @@ class XenAPIVolumeTestCase(test.TestCase): db.volume_destroy(context.get_admin_context(), vol['id']) def test_parse_volume_info_raise_exception(self): - """ This shows how to test helper classes' methods """ + """This shows how to test helper classes' methods.""" stubs.stubout_session(self.stubs, stubs.FakeSessionForVolumeTests) session = xenapi_conn.XenAPISession('test_url', 'root', 'test_pass') helper = volume_utils.VolumeHelper @@ -130,7 +126,7 @@ class XenAPIVolumeTestCase(test.TestCase): db.volume_destroy(context.get_admin_context(), vol['id']) def test_attach_volume(self): - """ This shows how to test Ops classes' methods """ + """This shows how to test Ops classes' methods.""" stubs.stubout_session(self.stubs, stubs.FakeSessionForVolumeTests) conn = xenapi_conn.get_connection(False) volume = self._create_volume() @@ -149,7 +145,7 @@ class XenAPIVolumeTestCase(test.TestCase): check() def test_attach_volume_raise_exception(self): - """ This shows how to test when exceptions are raised """ + """This shows how to test when exceptions are raised.""" stubs.stubout_session(self.stubs, stubs.FakeSessionForVolumeFailedTests) conn = xenapi_conn.get_connection(False) @@ -172,9 +168,7 @@ def reset_network(*args): class XenAPIVMTestCase(test.TestCase): - """ - Unit tests for VM operations - """ + """Unit tests for VM operations.""" def setUp(self): super(XenAPIVMTestCase, self).setUp() self.manager = manager.AuthManager() @@ -188,6 +182,7 @@ class XenAPIVMTestCase(test.TestCase): instance_name_template='%d') xenapi_fake.reset() xenapi_fake.create_local_srs() + xenapi_fake.create_local_pifs() db_fakes.stub_out_db_instance_api(self.stubs) xenapi_fake.create_network('fake', FLAGS.flat_network_bridge) stubs.stubout_session(self.stubs, stubs.FakeSessionForVMTests) @@ -247,12 +242,12 @@ class XenAPIVMTestCase(test.TestCase): check() - def create_vm_record(self, conn, os_type): + def create_vm_record(self, conn, os_type, instance_id=1): instances = conn.list_instances() - self.assertEquals(instances, ['1']) + self.assertEquals(instances, [str(instance_id)]) # Get Nova record for VM - vm_info = conn.get_info(1) + vm_info = conn.get_info(instance_id) # Get XenAPI record for VM vms = [rec for ref, rec in xenapi_fake.get_all_records('VM').iteritems() @@ -286,19 +281,19 @@ class XenAPIVMTestCase(test.TestCase): key = 'vm-data/networking/aabbccddeeff' xenstore_value = xenstore_data[key] tcpip_data = ast.literal_eval(xenstore_value) - self.assertEquals(tcpip_data, { - 'label': 'test_network', - 'broadcast': '10.0.0.255', - 'ips': [{'ip': '10.0.0.3', - 'netmask':'255.255.255.0', - 'enabled':'1'}], - 'ip6s': [{'ip': 'fe80::a8bb:ccff:fedd:eeff', - 'netmask': '120', - 'enabled': '1', - 'gateway': 'fe80::a00:1'}], - 'mac': 'aa:bb:cc:dd:ee:ff', - 'dns': ['10.0.0.2'], - 'gateway': '10.0.0.1'}) + self.assertEquals(tcpip_data, + {'label': 'fake_flat_network', + 'broadcast': '10.0.0.255', + 'ips': [{'ip': '10.0.0.3', + 'netmask':'255.255.255.0', + 'enabled':'1'}], + 'ip6s': [{'ip': 'fe80::a8bb:ccff:fedd:eeff', + 'netmask': '120', + 'enabled': '1', + 'gateway': 'fe80::a00:1'}], + 'mac': 'aa:bb:cc:dd:ee:ff', + 'dns': ['10.0.0.2'], + 'gateway': '10.0.0.1'}) def check_vm_params_for_windows(self): self.assertEquals(self.vm['platform']['nx'], 'true') @@ -334,9 +329,9 @@ class XenAPIVMTestCase(test.TestCase): def _test_spawn(self, image_id, kernel_id, ramdisk_id, instance_type="m1.large", os_type="linux", - check_injection=False): + instance_id=1, check_injection=False): stubs.stubout_loopingcall_start(self.stubs) - values = {'id': 1, + values = {'id': instance_id, 'project_id': self.project.id, 'user_id': self.user.id, 'image_id': image_id, @@ -347,7 +342,7 @@ class XenAPIVMTestCase(test.TestCase): 'os_type': os_type} instance = db.instance_create(self.context, values) self.conn.spawn(instance) - self.create_vm_record(self.conn, os_type) + self.create_vm_record(self.conn, os_type, instance_id) self.check_vm_record(self.conn, check_injection) def test_spawn_not_enough_memory(self): @@ -468,6 +463,28 @@ class XenAPIVMTestCase(test.TestCase): # guest agent is detected self.assertFalse(self._tee_executed) + def test_spawn_vlanmanager(self): + self.flags(xenapi_image_service='glance', + network_manager='nova.network.manager.VlanManager', + network_driver='nova.network.xenapi_net', + vlan_interface='fake0') + # Reset network table + xenapi_fake.reset_table('network') + # Instance id = 2 will use vlan network (see db/fakes.py) + fake_instance_id = 2 + network_bk = self.network + # Ensure we use xenapi_net driver + self.network = utils.import_object(FLAGS.network_manager) + self.network.setup_compute_network(None, fake_instance_id) + self._test_spawn(glance_stubs.FakeGlance.IMAGE_MACHINE, + glance_stubs.FakeGlance.IMAGE_KERNEL, + glance_stubs.FakeGlance.IMAGE_RAMDISK, + instance_id=fake_instance_id) + # TODO(salvatore-orlando): a complete test here would require + # a check for making sure the bridge for the VM's VIF is + # consistent with bridge specified in nova db + self.network = network_bk + def test_spawn_with_network_qos(self): self._create_instance() for vif_ref in xenapi_fake.get_all('VIF'): @@ -497,7 +514,7 @@ class XenAPIVMTestCase(test.TestCase): self.stubs.UnsetAll() def _create_instance(self): - """Creates and spawns a test instance""" + """Creates and spawns a test instance.""" stubs.stubout_loopingcall_start(self.stubs) values = { 'id': 1, @@ -515,9 +532,7 @@ class XenAPIVMTestCase(test.TestCase): class XenAPIDiffieHellmanTestCase(test.TestCase): - """ - Unit tests for Diffie-Hellman code - """ + """Unit tests for Diffie-Hellman code.""" def setUp(self): super(XenAPIDiffieHellmanTestCase, self).setUp() self.alice = SimpleDH() @@ -541,9 +556,7 @@ class XenAPIDiffieHellmanTestCase(test.TestCase): class XenAPIMigrateInstance(test.TestCase): - """ - Unit test for verifying migration-related actions - """ + """Unit test for verifying migration-related actions.""" def setUp(self): super(XenAPIMigrateInstance, self).setUp() @@ -598,9 +611,7 @@ class XenAPIMigrateInstance(test.TestCase): class XenAPIDetermineDiskImageTestCase(test.TestCase): - """ - Unit tests for code that detects the ImageType - """ + """Unit tests for code that detects the ImageType.""" def setUp(self): super(XenAPIDetermineDiskImageTestCase, self).setUp() glance_stubs.stubout_glance_client(self.stubs, @@ -619,9 +630,7 @@ class XenAPIDetermineDiskImageTestCase(test.TestCase): self.assertEqual(disk_type, dt) def test_instance_disk(self): - """ - If a kernel is specified then the image type is DISK (aka machine) - """ + """If a kernel is specified, the image type is DISK (aka machine).""" FLAGS.xenapi_image_service = 'objectstore' self.fake_instance.image_id = glance_stubs.FakeGlance.IMAGE_MACHINE self.fake_instance.kernel_id = glance_stubs.FakeGlance.IMAGE_KERNEL diff --git a/nova/virt/disk.py b/nova/virt/disk.py index 25e4f54a9..ddea1a1f7 100644 --- a/nova/virt/disk.py +++ b/nova/virt/disk.py @@ -116,6 +116,41 @@ def inject_data(image, key=None, net=None, partition=None, nbd=False): _unlink_device(device, nbd) +def setup_container(image, container_dir=None, nbd=False): + """Setup the LXC container. + + It will mount the loopback image to the container directory in order + to create the root filesystem for the container. + + LXC does not support qcow2 images yet. + """ + try: + device = _link_device(image, nbd) + utils.execute('sudo', 'mount', device, container_dir) + except Exception, exn: + LOG.exception(_('Failed to mount filesystem: %s'), exn) + _unlink_device(device, nbd) + + +def destroy_container(target, instance, nbd=False): + """Destroy the container once it terminates. + + It will umount the container that is mounted, try to find the loopback + device associated with the container and delete it. + + LXC does not support qcow2 images yet. + """ + try: + container_dir = '%s/rootfs' % target + utils.execute('sudo', 'umount', container_dir) + finally: + out, err = utils.execute('sudo', 'losetup', '-a') + for loop in out.splitlines(): + if instance['name'] in loop: + device = loop.split(loop, ':') + _unlink_device(device, nbd) + + def _link_device(image, nbd): """Link image to device using loopback or nbd""" if nbd: diff --git a/nova/virt/driver.py b/nova/virt/driver.py index fcd31861d..eb9626d08 100644 --- a/nova/virt/driver.py +++ b/nova/virt/driver.py @@ -66,7 +66,19 @@ class ComputeDriver(object): raise NotImplementedError() def destroy(self, instance, cleanup=True): - """Shutdown specified VM""" + """Destroy (shutdown and delete) the specified instance. + + The given parameter is an instance of nova.compute.service.Instance, + and so the instance is being specified as instance.name. + + The work will be done asynchronously. This function returns a + task that allows the caller to detect when it is complete. + + If the instance is not found (for example if networking failed), this + function should still succeed. It's probably a good idea to log a + warning in that case. + + """ raise NotImplementedError() def reboot(self, instance): @@ -77,13 +89,6 @@ class ComputeDriver(object): raise NotImplementedError() def get_console_pool_info(self, console_type): - """??? - - Returns a dict containing: - :address: ??? - :username: ??? - :password: ??? - """ raise NotImplementedError() def get_console_output(self, instance): @@ -114,7 +119,7 @@ class ComputeDriver(object): raise NotImplementedError() def snapshot(self, instance, image_id): - """ Create snapshot from a running VM instance """ + """Create snapshot from a running VM instance.""" raise NotImplementedError() def finish_resize(self, instance, disk_info): diff --git a/nova/virt/fake.py b/nova/virt/fake.py index 7018f8c1b..c3d5230df 100644 --- a/nova/virt/fake.py +++ b/nova/virt/fake.py @@ -26,11 +26,15 @@ semantics of real hypervisor connections. """ from nova import exception +from nova import log as logging from nova import utils from nova.compute import power_state from nova.virt import driver +LOG = logging.getLogger('nova.compute.disk') + + def get_connection(_): # The read_only parameter is ignored. return FakeConnection.instance() @@ -256,16 +260,12 @@ class FakeConnection(driver.ComputeDriver): pass def destroy(self, instance): - """ - Destroy (shutdown and delete) the specified instance. - - The given parameter is an instance of nova.compute.service.Instance, - and so the instance is being specified as instance.name. - - The work will be done asynchronously. This function returns a - task that allows the caller to detect when it is complete. - """ - del self.instances[instance.name] + key = instance.name + if key in self.instances: + del self.instances[key] + else: + LOG.warning("Key '%s' not in instances '%s'" % + (key, self.instances)) def attach_volume(self, instance_name, device_path, mountpoint): """Attach the disk at device_path to the instance at mountpoint""" @@ -375,6 +375,11 @@ class FakeConnection(driver.ComputeDriver): 'host': 'fakeajaxconsole.com', 'port': 6969} + def get_vnc_console(self, instance): + return {'token': 'FAKETOKEN', + 'host': 'fakevncconsole.com', + 'port': 6969} + def get_console_pool_info(self, console_type): return {'address': '127.0.0.1', 'username': 'fakeuser', diff --git a/nova/virt/libvirt.xml.template b/nova/virt/libvirt.xml.template index d74a9e85b..de2497a76 100644 --- a/nova/virt/libvirt.xml.template +++ b/nova/virt/libvirt.xml.template @@ -2,7 +2,12 @@ <name>${name}</name> <memory>${memory_kb}</memory> <os> -#if $type == 'uml' +#if $type == 'lxc' + #set $disk_prefix = '' + #set $disk_bus = '' + <type>exe</type> + <init>/sbin/init</init> +#else if $type == 'uml' #set $disk_prefix = 'ubd' #set $disk_bus = 'uml' <type>uml</type> @@ -44,7 +49,13 @@ </features> <vcpu>${vcpus}</vcpu> <devices> -#if $getVar('rescue', False) +#if $type == 'lxc' + <filesystem type='mount'> + <source dir='${basepath}/rootfs'/> + <target dir='/'/> + </filesystem> +#else + #if $getVar('rescue', False) <disk type='file'> <driver type='${driver_type}'/> <source file='${basepath}/disk.rescue'/> @@ -55,18 +66,19 @@ <source file='${basepath}/disk'/> <target dev='${disk_prefix}b' bus='${disk_bus}'/> </disk> -#else + #else <disk type='file'> <driver type='${driver_type}'/> <source file='${basepath}/disk'/> <target dev='${disk_prefix}a' bus='${disk_bus}'/> </disk> - #if $getVar('local', False) - <disk type='file'> - <driver type='${driver_type}'/> - <source file='${basepath}/disk.local'/> - <target dev='${disk_prefix}b' bus='${disk_bus}'/> - </disk> + #if $getVar('local', False) + <disk type='file'> + <driver type='${driver_type}'/> + <source file='${basepath}/disk.local'/> + <target dev='${disk_prefix}b' bus='${disk_bus}'/> + </disk> + #end if #end if #end if @@ -87,7 +99,6 @@ </filterref> </interface> #end for - <!-- The order is significant here. File must be defined first --> <serial type="file"> <source path='${basepath}/console.log'/> @@ -104,5 +115,8 @@ <target port='0'/> </serial> +#if $getVar('vncserver_host', False) + <graphics type='vnc' port='-1' autoport='yes' keymap='en-us' listen='${vncserver_host}'/> +#end if </devices> </domain> diff --git a/nova/virt/libvirt_conn.py b/nova/virt/libvirt_conn.py index 80eb64f3c..b28584cb6 100644 --- a/nova/virt/libvirt_conn.py +++ b/nova/virt/libvirt_conn.py @@ -20,7 +20,7 @@ """ A connection to a hypervisor through libvirt. -Supports KVM, QEMU, UML, and XEN. +Supports KVM, LXC, QEMU, UML, and XEN. **Related Flags** @@ -60,6 +60,7 @@ from nova import flags from nova import log as logging #from nova import test from nova import utils +from nova import vnc from nova.auth import manager from nova.compute import instance_types from nova.compute import power_state @@ -86,7 +87,7 @@ flags.DEFINE_string('libvirt_xml_template', flags.DEFINE_string('libvirt_type', 'kvm', 'Libvirt domain type (valid options are: ' - 'kvm, qemu, uml, xen)') + 'kvm, lxc, qemu, uml, xen)') flags.DEFINE_string('libvirt_uri', '', 'Override the default libvirt URI (which is dependent' @@ -266,6 +267,8 @@ class LibvirtConnection(driver.ComputeDriver): uri = FLAGS.libvirt_uri or 'uml:///system' elif FLAGS.libvirt_type == 'xen': uri = FLAGS.libvirt_uri or 'xen:///' + elif FLAGS.libvirt_type == 'lxc': + uri = FLAGS.libvirt_uri or 'lxc:///' else: uri = FLAGS.libvirt_uri or 'qemu:///system' return uri @@ -344,6 +347,8 @@ class LibvirtConnection(driver.ComputeDriver): instance_name = instance['name'] LOG.info(_('instance %(instance_name)s: deleting instance files' ' %(target)s') % locals()) + if FLAGS.libvirt_type == 'lxc': + disk.destroy_container(target, instance, nbd=FLAGS.use_cow_images) if os.path.exists(target): shutil.rmtree(target) @@ -625,6 +630,9 @@ class LibvirtConnection(driver.ComputeDriver): instance['name']) data = self._flush_xen_console(virsh_output) fpath = self._append_to_file(data, console_log) + elif FLAGS.libvirt_type == 'lxc': + # LXC is also special + LOG.info(_("Unable to read LXC console")) else: fpath = console_log @@ -668,7 +676,23 @@ class LibvirtConnection(driver.ComputeDriver): subprocess.Popen(cmd, shell=True) return {'token': token, 'host': host, 'port': port} - _image_sems = {} + @exception.wrap_exception + def get_vnc_console(self, instance): + def get_vnc_port_for_instance(instance_name): + virt_dom = self._conn.lookupByName(instance_name) + xml = virt_dom.XMLDesc(0) + # TODO: use etree instead of minidom + dom = minidom.parseString(xml) + + for graphic in dom.getElementsByTagName('graphics'): + if graphic.getAttribute('type') == 'vnc': + return graphic.getAttribute('port') + + port = get_vnc_port_for_instance(instance['name']) + token = str(uuid.uuid4()) + host = instance['host'] + + return {'token': token, 'host': host, 'port': port} @staticmethod def _cache_image(fn, target, fname, cow=False, *args, **kwargs): @@ -738,6 +762,10 @@ class LibvirtConnection(driver.ComputeDriver): f.write(libvirt_xml) f.close() + if FLAGS.libvirt_type == 'lxc': + container_dir = '%s/rootfs' % basepath(suffix='') + utils.execute('mkdir', '-p', container_dir) + # NOTE(vish): No need add the suffix to console.log os.close(os.open(basepath('console.log', ''), os.O_CREAT | os.O_WRONLY, 0660)) @@ -797,12 +825,16 @@ class LibvirtConnection(driver.ComputeDriver): if not inst['kernel_id']: target_partition = "1" + if FLAGS.libvirt_type == 'lxc': + target_partition = None + key = str(inst['key_data']) net = None nets = [] ifc_template = open(FLAGS.injected_network_template).read() ifc_num = -1 + have_injected_networks = False admin_context = context.get_admin_context() for (network_ref, mapping) in network_info: ifc_num += 1 @@ -810,6 +842,7 @@ class LibvirtConnection(driver.ComputeDriver): if not 'injected' in network_ref: continue + have_injected_networks = True address = mapping['ips'][0]['ip'] address_v6 = None if FLAGS.use_ipv6: @@ -825,9 +858,10 @@ class LibvirtConnection(driver.ComputeDriver): 'netmask_v6': network_ref['netmask_v6']} nets.append(net_info) - net = str(Template(ifc_template, - searchList=[{'interfaces': nets, - 'use_ipv6': FLAGS.use_ipv6}])) + if have_injected_networks: + net = str(Template(ifc_template, + searchList=[{'interfaces': nets, + 'use_ipv6': FLAGS.use_ipv6}])) if key or net: inst_name = inst['name'] @@ -842,6 +876,11 @@ class LibvirtConnection(driver.ComputeDriver): disk.inject_data(basepath('disk'), key, net, partition=target_partition, nbd=FLAGS.use_cow_images) + + if FLAGS.libvirt_type == 'lxc': + disk.setup_container(basepath('disk'), + container_dir=container_dir, + nbd=FLAGS.use_cow_images) except Exception as e: # This could be a windows image, or a vmdk format disk LOG.warn(_('instance %(inst_name)s: ignoring error injecting' @@ -927,6 +966,8 @@ class LibvirtConnection(driver.ComputeDriver): 'driver_type': driver_type, 'nics': nics} + if FLAGS.vnc_enabled: + xml_info['vncserver_host'] = FLAGS.vncserver_host if not rescue: if instance['kernel_id']: xml_info['kernel'] = xml_info['basepath'] + "/kernel" diff --git a/nova/virt/xenapi/fake.py b/nova/virt/xenapi/fake.py index 18d558058..4434dbf0b 100644 --- a/nova/virt/xenapi/fake.py +++ b/nova/virt/xenapi/fake.py @@ -60,8 +60,8 @@ from nova import exception from nova import log as logging -_CLASSES = ['host', 'network', 'session', 'SR', 'VBD',\ - 'PBD', 'VDI', 'VIF', 'VM', 'task'] +_CLASSES = ['host', 'network', 'session', 'SR', 'VBD', + 'PBD', 'VDI', 'VIF', 'PIF', 'VM', 'VLAN', 'task'] _db_content = {} @@ -78,30 +78,36 @@ def reset(): for c in _CLASSES: _db_content[c] = {} create_host('fake') - create_vm('fake', 'Running', is_a_template=False, is_control_domain=True) + create_vm('fake', + 'Running', + is_a_template=False, + is_control_domain=True) + + +def reset_table(table): + if not table in _CLASSES: + return + _db_content[table] = {} def create_host(name_label): - return _create_object('host', { - 'name_label': name_label, - }) + return _create_object('host', + {'name_label': name_label}) def create_network(name_label, bridge): - return _create_object('network', { - 'name_label': name_label, - 'bridge': bridge, - }) + return _create_object('network', + {'name_label': name_label, + 'bridge': bridge}) def create_vm(name_label, status, is_a_template=False, is_control_domain=False): - return _create_object('VM', { - 'name_label': name_label, - 'power-state': status, - 'is_a_template': is_a_template, - 'is_control_domain': is_control_domain, - }) + return _create_object('VM', + {'name_label': name_label, + 'power-state': status, + 'is_a_template': is_a_template, + 'is_control_domain': is_control_domain}) def destroy_vm(vm_ref): @@ -123,27 +129,24 @@ def destroy_vdi(vdi_ref): def create_vdi(name_label, read_only, sr_ref, sharable): - return _create_object('VDI', { - 'name_label': name_label, - 'read_only': read_only, - 'SR': sr_ref, - 'type': '', - 'name_description': '', - 'sharable': sharable, - 'other_config': {}, - 'location': '', - 'xenstore_data': '', - 'sm_config': {}, - 'VBDs': {}, - }) + return _create_object('VDI', + {'name_label': name_label, + 'read_only': read_only, + 'SR': sr_ref, + 'type': '', + 'name_description': '', + 'sharable': sharable, + 'other_config': {}, + 'location': '', + 'xenstore_data': '', + 'sm_config': {}, + 'VBDs': {}}) def create_vbd(vm_ref, vdi_ref): - vbd_rec = { - 'VM': vm_ref, - 'VDI': vdi_ref, - 'currently_attached': False, - } + vbd_rec = {'VM': vm_ref, + 'VDI': vdi_ref, + 'currently_attached': False} vbd_ref = _create_object('VBD', vbd_rec) after_VBD_create(vbd_ref, vbd_rec) return vbd_ref @@ -169,19 +172,24 @@ def after_VM_create(vm_ref, vm_rec): def create_pbd(config, host_ref, sr_ref, attached): - return _create_object('PBD', { - 'device-config': config, - 'host': host_ref, - 'SR': sr_ref, - 'currently-attached': attached, - }) + return _create_object('PBD', + {'device-config': config, + 'host': host_ref, + 'SR': sr_ref, + 'currently-attached': attached}) def create_task(name_label): - return _create_object('task', { - 'name_label': name_label, - 'status': 'pending', - }) + return _create_object('task', + {'name_label': name_label, + 'status': 'pending'}) + + +def create_local_pifs(): + """Adds a PIF for each to the local database with VLAN=-1. + Do this one per host.""" + for host_ref in _db_content['host'].keys(): + _create_local_pif(host_ref) def create_local_srs(): @@ -192,25 +200,34 @@ def create_local_srs(): def _create_local_sr(host_ref): - sr_ref = _create_object('SR', { - 'name_label': 'Local storage', - 'type': 'lvm', - 'content_type': 'user', - 'shared': False, - 'physical_size': str(1 << 30), - 'physical_utilisation': str(0), - 'virtual_allocation': str(0), - 'other_config': { - 'i18n-original-value-name_label': 'Local storage', - 'i18n-key': 'local-storage', - }, - 'VDIs': [] - }) + sr_ref = _create_object( + 'SR', + {'name_label': 'Local storage', + 'type': 'lvm', + 'content_type': 'user', + 'shared': False, + 'physical_size': str(1 << 30), + 'physical_utilisation': str(0), + 'virtual_allocation': str(0), + 'other_config': { + 'i18n-original-value-name_label': 'Local storage', + 'i18n-key': 'local-storage'}, + 'VDIs': []}) pbd_ref = create_pbd('', host_ref, sr_ref, True) _db_content['SR'][sr_ref]['PBDs'] = [pbd_ref] return sr_ref +def _create_local_pif(host_ref): + pif_ref = _create_object('PIF', + {'name-label': 'Fake PIF', + 'MAC': '00:11:22:33:44:55', + 'physical': True, + 'VLAN': -1, + 'device': 'fake0', + 'host_uuid': host_ref}) + + def _create_object(table, obj): ref = str(uuid.uuid4()) obj['uuid'] = str(uuid.uuid4()) @@ -234,6 +251,21 @@ def _create_sr(table, obj): return sr_ref +def _create_vlan(pif_ref, vlan_num, network_ref): + pif_rec = get_record('PIF', pif_ref) + vlan_pif_ref = _create_object('PIF', + {'name-label': 'Fake VLAN PIF', + 'MAC': '00:11:22:33:44:55', + 'physical': True, + 'VLAN': vlan_num, + 'device': pif_rec['device'], + 'host_uuid': pif_rec['host_uuid']}) + return _create_object('VLAN', + {'tagged-pif': pif_ref, + 'untagged-pif': vlan_pif_ref, + 'tag': vlan_num}) + + def get_all(table): return _db_content[table].keys() @@ -292,6 +324,10 @@ class SessionBase(object): rec['currently_attached'] = False rec['device'] = '' + def PIF_get_all_records_where(self, _1, _2): + # TODO (salvatore-orlando): filter table on _2 + return _db_content['PIF'] + def VM_get_xenstore_data(self, _1, vm_ref): return _db_content['VM'][vm_ref].get('xenstore_data', '') @@ -302,7 +338,7 @@ class SessionBase(object): db_ref['xenstore_data'][key] = None def network_get_all_records_where(self, _1, _2): - # TODO (salvatore-orlando):filter table on _2 + # TODO (salvatore-orlando): filter table on _2 return _db_content['network'] def VM_add_to_xenstore_data(self, _1, vm_ref, key, value): @@ -318,6 +354,9 @@ class SessionBase(object): def host_call_plugin(*args): return 'herp' + def network_get_all_records_where(self, _1, filter): + return self.xenapi.network.get_all_records() + def xenapi_request(self, methodname, params): if methodname.startswith('login'): self._login(methodname, params) @@ -337,10 +376,9 @@ class SessionBase(object): def _login(self, method, params): self._session = str(uuid.uuid4()) - _db_content['session'][self._session] = { - 'uuid': str(uuid.uuid4()), - 'this_host': _db_content['host'].keys()[0], - } + _db_content['session'][self._session] = \ + {'uuid': str(uuid.uuid4()), + 'this_host': _db_content['host'].keys()[0]} def _logout(self): s = self._session @@ -456,12 +494,16 @@ class SessionBase(object): def _create(self, name, params): self._check_session(params) is_sr_create = name == 'SR.create' + is_vlan_create = name == 'VLAN.create' # Storage Repositories have a different API - expected = is_sr_create and 10 or 2 + expected = is_sr_create and 10 or is_vlan_create and 4 or 2 self._check_arg_count(params, expected) (cls, _) = name.split('.') ref = is_sr_create and \ - _create_sr(cls, params) or _create_object(cls, params[1]) + _create_sr(cls, params) or \ + is_vlan_create and \ + _create_vlan(params[1], params[2], params[3]) or \ + _create_object(cls, params[1]) # Call hook to provide any fixups needed (ex. creating backrefs) after_hook = 'after_%s_create' % cls diff --git a/nova/virt/xenapi/network_utils.py b/nova/virt/xenapi/network_utils.py index c0406d8f0..94d8e5199 100644 --- a/nova/virt/xenapi/network_utils.py +++ b/nova/virt/xenapi/network_utils.py @@ -28,11 +28,26 @@ class NetworkHelper(HelperBase): """ The class that wraps the helper methods together. """ + @classmethod + def find_network_with_name_label(cls, session, name_label): + networks = session.call_xenapi('network.get_by_name_label', name_label) + if len(networks) == 1: + return networks[0] + elif len(networks) > 1: + raise Exception(_('Found non-unique network' + ' for name_label %s') % name_label) + else: + return None @classmethod def find_network_with_bridge(cls, session, bridge): - """Return the network on which the bridge is attached, if found.""" - expr = 'field "bridge" = "%s"' % bridge + """ + Return the network on which the bridge is attached, if found. + The bridge is defined in the nova db and can be found either in the + 'bridge' or 'name_label' fields of the XenAPI network record. + """ + expr = 'field "name__label" = "%s" or ' \ + 'field "bridge" = "%s"' % (bridge, bridge) networks = session.call_xenapi('network.get_all_records_where', expr) if len(networks) == 1: return networks.keys()[0] diff --git a/nova/virt/xenapi/vm_utils.py b/nova/virt/xenapi/vm_utils.py index 2288ea8a5..d07d60800 100644 --- a/nova/virt/xenapi/vm_utils.py +++ b/nova/virt/xenapi/vm_utils.py @@ -1108,11 +1108,13 @@ def _prepare_injectables(inst, networks_info): if networks_info: ifc_num = -1 interfaces_info = [] + have_injected_networks = False for (network_ref, info) in networks_info: ifc_num += 1 if not network_ref['injected']: continue + have_injected_networks = True ip_v4 = ip_v6 = None if 'ips' in info and len(info['ips']) > 0: ip_v4 = info['ips'][0] @@ -1131,7 +1133,9 @@ def _prepare_injectables(inst, networks_info): 'gateway_v6': ip_v6 and ip_v6['gateway'] or '', 'use_ipv6': FLAGS.use_ipv6} interfaces_info.append(interface_info) - net = str(template(template_data, - searchList=[{'interfaces': interfaces_info, - 'use_ipv6': FLAGS.use_ipv6}])) + + if have_injected_networks: + net = str(template(template_data, + searchList=[{'interfaces': interfaces_info, + 'use_ipv6': FLAGS.use_ipv6}])) return key, net diff --git a/nova/virt/xenapi/vmops.py b/nova/virt/xenapi/vmops.py index 0235e2dc4..c96c35a6e 100644 --- a/nova/virt/xenapi/vmops.py +++ b/nova/virt/xenapi/vmops.py @@ -58,7 +58,7 @@ class VMOps(object): VMHelper.XenAPI = self.XenAPI def list_instances(self): - """List VM instances""" + """List VM instances.""" # TODO(justinsb): Should we just always use the details method? # Seems to be the same number of API calls.. vm_refs = [] @@ -69,7 +69,7 @@ class VMOps(object): return vm_refs def list_instances_detail(self): - """List VM instances, returning InstanceInfo objects""" + """List VM instances, returning InstanceInfo objects.""" instance_infos = [] for vm_ref in self._session.get_xenapi().VM.get_all(): vm_rec = self._session.get_xenapi().VM.get_record(vm_ref) @@ -119,11 +119,11 @@ class VMOps(object): self._spawn(instance, vm_ref) def spawn_rescue(self, instance): - """Spawn a rescue instance""" + """Spawn a rescue instance.""" self.spawn(instance) def _create_vm(self, instance, vdi_uuid, network_info=None): - """Create VM instance""" + """Create VM instance.""" instance_name = instance.name vm_ref = VMHelper.lookup(self._session, instance_name) if vm_ref is not None: @@ -180,7 +180,7 @@ class VMOps(object): return vm_ref def _spawn(self, instance, vm_ref): - """Spawn a new instance""" + """Spawn a new instance.""" LOG.debug(_('Starting VM %s...'), vm_ref) self._start(instance, vm_ref) instance_name = instance.name @@ -236,7 +236,8 @@ class VMOps(object): return timer.start(interval=0.5, now=True) def _get_vm_opaque_ref(self, instance_or_vm): - """Refactored out the common code of many methods that receive either + """ + Refactored out the common code of many methods that receive either a vm name or a vm instance, and want a vm instance in return. """ # if instance_or_vm is a string it must be opaque ref or instance name @@ -264,21 +265,21 @@ class VMOps(object): return vm_ref def _acquire_bootlock(self, vm): - """Prevent an instance from booting""" + """Prevent an instance from booting.""" self._session.call_xenapi( "VM.set_blocked_operations", vm, {"start": ""}) def _release_bootlock(self, vm): - """Allow an instance to boot""" + """Allow an instance to boot.""" self._session.call_xenapi( "VM.remove_from_blocked_operations", vm, "start") def snapshot(self, instance, image_id): - """Create snapshot from a running VM instance + """Create snapshot from a running VM instance. :param instance: instance to be snapshotted :param image_id: id of image to upload to @@ -298,6 +299,7 @@ class VMOps(object): 3. Push-to-glance: Once coalesced, we call a plugin on the XenServer that will bundle the VHDs together and then push the bundle into Glance. + """ template_vm_ref = None try: @@ -330,11 +332,12 @@ class VMOps(object): return def migrate_disk_and_power_off(self, instance, dest): - """Copies a VHD from one host machine to another + """Copies a VHD from one host machine to another. + + :param instance: the instance that owns the VHD in question. + :param dest: the destination host machine. + :param disk_type: values are 'primary' or 'cow'. - :param instance: the instance that owns the VHD in question - :param dest: the destination host machine - :param disk_type: values are 'primary' or 'cow' """ vm_ref = VMHelper.lookup(self._session, instance.name) @@ -383,7 +386,7 @@ class VMOps(object): return {'base_copy': base_copy_uuid, 'cow': cow_uuid} def link_disks(self, instance, base_copy_uuid, cow_uuid): - """Links the base copy VHD to the COW via the XAPI plugin""" + """Links the base copy VHD to the COW via the XAPI plugin.""" vm_ref = VMHelper.lookup(self._session, instance.name) new_base_copy_uuid = str(uuid.uuid4()) new_cow_uuid = str(uuid.uuid4()) @@ -404,7 +407,7 @@ class VMOps(object): return new_cow_uuid def resize_instance(self, instance, vdi_uuid): - """Resize a running instance by changing it's RAM and disk size """ + """Resize a running instance by changing it's RAM and disk size.""" #TODO(mdietz): this will need to be adjusted for swap later #The new disk size must be in bytes @@ -418,18 +421,20 @@ class VMOps(object): LOG.debug(_("Resize instance %s complete") % (instance.name)) def reboot(self, instance): - """Reboot VM instance""" + """Reboot VM instance.""" vm_ref = self._get_vm_opaque_ref(instance) task = self._session.call_xenapi('Async.VM.clean_reboot', vm_ref) self._session.wait_for_task(task, instance.id) def set_admin_password(self, instance, new_pass): - """Set the root/admin password on the VM instance. This is done via - an agent running on the VM. Communication between nova and the agent - is done via writing xenstore records. Since communication is done over - the XenAPI RPC calls, we need to encrypt the password. We're using a - simple Diffie-Hellman class instead of the more advanced one in - M2Crypto for compatibility with the agent code. + """Set the root/admin password on the VM instance. + + This is done via an agent running on the VM. Communication between nova + and the agent is done via writing xenstore records. Since communication + is done over the XenAPI RPC calls, we need to encrypt the password. + We're using a simple Diffie-Hellman class instead of the more advanced + one in M2Crypto for compatibility with the agent code. + """ # Need to uniquely identify this request. transaction_id = str(uuid.uuid4()) @@ -462,11 +467,14 @@ class VMOps(object): return resp_dict['message'] def inject_file(self, instance, path, contents): - """Write a file to the VM instance. The path to which it is to be - written and the contents of the file need to be supplied; both will - be base64-encoded to prevent errors with non-ASCII characters being - transmitted. If the agent does not support file injection, or the user - has disabled it, a NotImplementedError will be raised. + """Write a file to the VM instance. + + The path to which it is to be written and the contents of the file + need to be supplied; both will be base64-encoded to prevent errors + with non-ASCII characters being transmitted. If the agent does not + support file injection, or the user has disabled it, a + NotImplementedError will be raised. + """ # Files/paths must be base64-encoded for transmission to agent b64_path = base64.b64encode(path) @@ -487,7 +495,7 @@ class VMOps(object): return resp_dict['message'] def _shutdown(self, instance, vm_ref, hard=True): - """Shutdown an instance""" + """Shutdown an instance.""" state = self.get_info(instance['name'])['state'] if state == power_state.SHUTDOWN: instance_name = instance.name @@ -511,11 +519,11 @@ class VMOps(object): LOG.exception(exc) def _shutdown_rescue(self, rescue_vm_ref): - """Shutdown a rescue instance""" + """Shutdown a rescue instance.""" self._session.call_xenapi("Async.VM.hard_shutdown", rescue_vm_ref) def _destroy_vdis(self, instance, vm_ref): - """Destroys all VDIs associated with a VM""" + """Destroys all VDIs associated with a VM.""" instance_id = instance.id LOG.debug(_("Destroying VDIs for Instance %(instance_id)s") % locals()) @@ -532,7 +540,7 @@ class VMOps(object): LOG.exception(exc) def _destroy_rescue_vdis(self, rescue_vm_ref): - """Destroys all VDIs associated with a rescued VM""" + """Destroys all VDIs associated with a rescued VM.""" vdi_refs = VMHelper.lookup_vm_vdis(self._session, rescue_vm_ref) for vdi_ref in vdi_refs: try: @@ -541,7 +549,7 @@ class VMOps(object): continue def _destroy_rescue_vbds(self, rescue_vm_ref): - """Destroys all VBDs tied to a rescue VM""" + """Destroys all VBDs tied to a rescue VM.""" vbd_refs = self._session.get_xenapi().VM.get_VBDs(rescue_vm_ref) for vbd_ref in vbd_refs: vbd_rec = self._session.get_xenapi().VBD.get_record(vbd_ref) @@ -550,8 +558,7 @@ class VMOps(object): VMHelper.destroy_vbd(self._session, vbd_ref) def _destroy_kernel_ramdisk(self, instance, vm_ref): - """ - Three situations can occur: + """Three situations can occur: 1. We have neither a ramdisk nor a kernel, in which case we are a RAW image and can omit this step @@ -561,6 +568,7 @@ class VMOps(object): 3. We have both, in which case we safely remove both the kernel and the ramdisk. + """ instance_id = instance.id if not instance.kernel_id and not instance.ramdisk_id: @@ -589,7 +597,7 @@ class VMOps(object): LOG.debug(_("kernel/ramdisk files removed")) def _destroy_vm(self, instance, vm_ref): - """Destroys a VM record""" + """Destroys a VM record.""" instance_id = instance.id try: task = self._session.call_xenapi('Async.VM.destroy', vm_ref) @@ -600,7 +608,7 @@ class VMOps(object): LOG.debug(_("Instance %(instance_id)s VM destroyed") % locals()) def _destroy_rescue_instance(self, rescue_vm_ref): - """Destroy a rescue instance""" + """Destroy a rescue instance.""" self._destroy_rescue_vbds(rescue_vm_ref) self._shutdown_rescue(rescue_vm_ref) self._destroy_rescue_vdis(rescue_vm_ref) @@ -608,11 +616,11 @@ class VMOps(object): self._session.call_xenapi("Async.VM.destroy", rescue_vm_ref) def destroy(self, instance): - """ - Destroy VM instance + """Destroy VM instance. This is the method exposed by xenapi_conn.destroy(). The rest of the destroy_* methods are internal. + """ instance_id = instance.id LOG.info(_("Destroying VM for Instance %(instance_id)s") % locals()) @@ -621,13 +629,13 @@ class VMOps(object): def _destroy(self, instance, vm_ref, shutdown=True, destroy_kernel_ramdisk=True): - """ - Destroys VM instance by performing: + """Destroys VM instance by performing: + + 1. A shutdown if requested. + 2. Destroying associated VDIs. + 3. Destroying kernel and ramdisk files (if necessary). + 4. Destroying that actual VM record. - 1. A shutdown if requested - 2. Destroying associated VDIs - 3. Destroying kernel and ramdisk files (if necessary) - 4. Destroying that actual VM record """ if vm_ref is None: LOG.warning(_("VM is not present, skipping destroy...")) @@ -650,35 +658,36 @@ class VMOps(object): callback(ret) def pause(self, instance, callback): - """Pause VM instance""" + """Pause VM instance.""" vm_ref = self._get_vm_opaque_ref(instance) task = self._session.call_xenapi('Async.VM.pause', vm_ref) self._wait_with_callback(instance.id, task, callback) def unpause(self, instance, callback): - """Unpause VM instance""" + """Unpause VM instance.""" vm_ref = self._get_vm_opaque_ref(instance) task = self._session.call_xenapi('Async.VM.unpause', vm_ref) self._wait_with_callback(instance.id, task, callback) def suspend(self, instance, callback): - """suspend the specified instance""" + """Suspend the specified instance.""" vm_ref = self._get_vm_opaque_ref(instance) task = self._session.call_xenapi('Async.VM.suspend', vm_ref) self._wait_with_callback(instance.id, task, callback) def resume(self, instance, callback): - """resume the specified instance""" + """Resume the specified instance.""" vm_ref = self._get_vm_opaque_ref(instance) task = self._session.call_xenapi('Async.VM.resume', vm_ref, False, True) self._wait_with_callback(instance.id, task, callback) def rescue(self, instance, callback): - """Rescue the specified instance - - shutdown the instance VM - - set 'bootlock' to prevent the instance from starting in rescue - - spawn a rescue VM (the vm name-label will be instance-N-rescue) + """Rescue the specified instance. + + - shutdown the instance VM. + - set 'bootlock' to prevent the instance from starting in rescue. + - spawn a rescue VM (the vm name-label will be instance-N-rescue). """ rescue_vm_ref = VMHelper.lookup(self._session, @@ -702,10 +711,11 @@ class VMOps(object): self._session.call_xenapi("Async.VBD.plug", rescue_vbd_ref) def unrescue(self, instance, callback): - """Unrescue the specified instance - - unplug the instance VM's disk from the rescue VM - - teardown the rescue VM - - release the bootlock to allow the instance VM to start + """Unrescue the specified instance. + + - unplug the instance VM's disk from the rescue VM. + - teardown the rescue VM. + - release the bootlock to allow the instance VM to start. """ rescue_vm_ref = VMHelper.lookup(self._session, @@ -723,9 +733,11 @@ class VMOps(object): self._start(instance, original_vm_ref) def poll_rescued_instances(self, timeout): - """Look for expirable rescued instances + """Look for expirable rescued instances. + - forcibly exit rescue mode for any instances that have been in rescue mode for >= the provided timeout + """ last_ran = self.poll_rescue_last_ran if not last_ran: @@ -761,30 +773,30 @@ class VMOps(object): False) def get_info(self, instance): - """Return data about VM instance""" + """Return data about VM instance.""" vm_ref = self._get_vm_opaque_ref(instance) vm_rec = self._session.get_xenapi().VM.get_record(vm_ref) return VMHelper.compile_info(vm_rec) def get_diagnostics(self, instance): - """Return data about VM diagnostics""" + """Return data about VM diagnostics.""" vm_ref = self._get_vm_opaque_ref(instance) vm_rec = self._session.get_xenapi().VM.get_record(vm_ref) return VMHelper.compile_diagnostics(self._session, vm_rec) def get_console_output(self, instance): - """Return snapshot of console""" + """Return snapshot of console.""" # TODO: implement this to fix pylint! return 'FAKE CONSOLE OUTPUT of instance' def get_ajax_console(self, instance): - """Return link to instance's ajax console""" + """Return link to instance's ajax console.""" # TODO: implement this! return 'http://fakeajaxconsole/fake_url' # TODO(tr3buchet) - remove this function after nova multi-nic def _get_network_info(self, instance): - """creates network info list for instance""" + """Creates network info list for instance.""" admin_context = context.get_admin_context() IPs = db.fixed_ip_get_all_by_instance(admin_context, instance['id']) @@ -826,7 +838,7 @@ class VMOps(object): def inject_network_info(self, instance, vm_ref, network_info): """ Generate the network info and make calls to place it into the - xenstore and the xenstore param list + xenstore and the xenstore param list. """ logging.debug(_("injecting network info to xs for vm: |%s|"), vm_ref) @@ -847,7 +859,7 @@ class VMOps(object): pass def create_vifs(self, vm_ref, network_info): - """Creates vifs for an instance""" + """Creates vifs for an instance.""" logging.debug(_("creating vif(s) for vm: |%s|"), vm_ref) # this function raises if vm_ref is not a vm_opaque_ref @@ -858,8 +870,8 @@ class VMOps(object): bridge = network['bridge'] rxtx_cap = info.pop('rxtx_cap') network_ref = \ - NetworkHelper.find_network_with_bridge(self._session, bridge) - + NetworkHelper.find_network_with_bridge(self._session, + bridge) VMHelper.create_vif(self._session, vm_ref, network_ref, mac_address, device, rxtx_cap) @@ -872,7 +884,8 @@ class VMOps(object): args, vm_ref) def list_from_xenstore(self, vm, path): - """Runs the xenstore-ls command to get a listing of all records + """ + Runs the xenstore-ls command to get a listing of all records from 'path' downward. Returns a dict with the sub-paths as keys, and the value stored in those paths as values. If nothing is found at that path, returns None. @@ -881,7 +894,8 @@ class VMOps(object): return json.loads(ret) def read_from_xenstore(self, vm, path): - """Returns the value stored in the xenstore record for the given VM + """ + Returns the value stored in the xenstore record for the given VM at the specified location. A XenAPIPlugin.PluginError will be raised if any error is encountered in the read process. """ @@ -897,7 +911,8 @@ class VMOps(object): return ret def write_to_xenstore(self, vm, path, value): - """Writes the passed value to the xenstore record for the given VM + """ + Writes the passed value to the xenstore record for the given VM at the specified location. A XenAPIPlugin.PluginError will be raised if any error is encountered in the write process. """ @@ -905,7 +920,8 @@ class VMOps(object): {'value': json.dumps(value)}) def clear_xenstore(self, vm, path): - """Deletes the VM's xenstore record for the specified path. + """ + Deletes the VM's xenstore record for the specified path. If there is no such record, the request is ignored. """ self._make_xenstore_call('delete_record', vm, path) @@ -922,7 +938,8 @@ class VMOps(object): def _make_plugin_call(self, plugin, method, vm, path, addl_args=None, vm_ref=None): - """Abstracts out the process of calling a method of a xenapi plugin. + """ + Abstracts out the process of calling a method of a xenapi plugin. Any errors raised by the plugin will in turn raise a RuntimeError here. """ instance_id = vm.id @@ -952,7 +969,8 @@ class VMOps(object): return ret def add_to_xenstore(self, vm, path, key, value): - """Adds the passed key/value pair to the xenstore record for + """ + Adds the passed key/value pair to the xenstore record for the given VM at the specified location. A XenAPIPlugin.PluginError will be raised if any error is encountered in the write process. """ @@ -965,7 +983,8 @@ class VMOps(object): self.write_to_xenstore(vm, path, current) def remove_from_xenstore(self, vm, path, key_or_keys): - """Takes either a single key or a list of keys and removes + """ + Takes either a single key or a list of keys and removes them from the xenstoreirecord data for the given VM. If the key doesn't exist, the request is ignored. """ @@ -992,7 +1011,8 @@ class VMOps(object): ###### names to distinguish them. (dabo) ######################################################################## def read_partial_from_param_xenstore(self, instance_or_vm, key_prefix): - """Returns a dict of all the keys in the xenstore parameter record + """ + Returns a dict of all the keys in the xenstore parameter record for the given instance that begin with the key_prefix. """ data = self.read_from_param_xenstore(instance_or_vm) @@ -1003,7 +1023,8 @@ class VMOps(object): return data def read_from_param_xenstore(self, instance_or_vm, keys=None): - """Returns the xenstore parameter record data for the specified VM + """ + Returns the xenstore parameter record data for the specified VM instance as a dict. Accepts an optional key or list of keys; if a value for 'keys' is passed, the returned dict is filtered to only return the values for those keys. @@ -1025,9 +1046,11 @@ class VMOps(object): return ret def add_to_param_xenstore(self, instance_or_vm, key, val): - """Takes a key/value pair and adds it to the xenstore parameter + """ + Takes a key/value pair and adds it to the xenstore parameter record for the given vm instance. If the key exists in xenstore, - it is overwritten""" + it is overwritten + """ vm_ref = self._get_vm_opaque_ref(instance_or_vm) self.remove_from_param_xenstore(instance_or_vm, key) jsonval = json.dumps(val) @@ -1035,7 +1058,8 @@ class VMOps(object): (vm_ref, key, jsonval)) def write_to_param_xenstore(self, instance_or_vm, mapping): - """Takes a dict and writes each key/value pair to the xenstore + """ + Takes a dict and writes each key/value pair to the xenstore parameter record for the given vm instance. Any existing data for those keys is overwritten. """ @@ -1043,7 +1067,8 @@ class VMOps(object): self.add_to_param_xenstore(instance_or_vm, k, v) def remove_from_param_xenstore(self, instance_or_vm, key_or_keys): - """Takes either a single key or a list of keys and removes + """ + Takes either a single key or a list of keys and removes them from the xenstore parameter record data for the given VM. If the key doesn't exist, the request is ignored. """ @@ -1069,7 +1094,8 @@ def _runproc(cmd): class SimpleDH(object): - """This class wraps all the functionality needed to implement + """ + This class wraps all the functionality needed to implement basic Diffie-Hellman-Merkle key exchange in Python. It features intelligent defaults for the prime and base numbers needed for the calculation, while allowing you to supply your own. It requires that @@ -1078,7 +1104,8 @@ class SimpleDH(object): is not available, a RuntimeError will be raised. """ def __init__(self, prime=None, base=None, secret=None): - """You can specify the values for prime and base if you wish; + """ + You can specify the values for prime and base if you wish; otherwise, reasonable default values will be used. """ if prime is None: diff --git a/nova/vnc/__init__.py b/nova/vnc/__init__.py new file mode 100644 index 000000000..b5b00e44e --- /dev/null +++ b/nova/vnc/__init__.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2010 Openstack, LLC. +# 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. + +"""Module for VNC Proxying.""" + +from nova import flags + + +FLAGS = flags.FLAGS +flags.DEFINE_string('vncproxy_topic', 'vncproxy', + 'the topic vnc proxy nodes listen on') +flags.DEFINE_string('vncproxy_url', + 'http://127.0.0.1:6080', + 'location of vnc console proxy, \ + in the form "http://127.0.0.1:6080"') +flags.DEFINE_string('vncserver_host', '0.0.0.0', + 'the host interface on which vnc server should listen') +flags.DEFINE_bool('vnc_enabled', True, + 'enable vnc related features') diff --git a/nova/vnc/auth.py b/nova/vnc/auth.py new file mode 100644 index 000000000..ce5e10388 --- /dev/null +++ b/nova/vnc/auth.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2010 Openstack, LLC. +# 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. + +"""Auth Components for VNC Console.""" + +import time +import urlparse +import webob + +from webob import Request + +from nova import context +from nova import flags +from nova import log as logging +from nova import manager +from nova import rpc +from nova import utils +from nova import wsgi +from nova import vnc + + +LOG = logging.getLogger('nova.vnc-proxy') +FLAGS = flags.FLAGS + + +class VNCNovaAuthMiddleware(object): + """Implementation of Middleware to Handle Nova Auth.""" + + def __init__(self, app): + self.app = app + self.token_cache = {} + utils.LoopingCall(self.delete_expired_cache_items).start(1) + + @webob.dec.wsgify + def __call__(self, req): + token = req.params.get('token') + + if not token: + referrer = req.environ.get('HTTP_REFERER') + auth_params = urlparse.parse_qs(urlparse.urlparse(referrer).query) + if 'token' in auth_params: + token = auth_params['token'][0] + + connection_info = self.get_token_info(token) + if not connection_info: + LOG.audit(_("Unauthorized Access: (%s)"), req.environ) + return webob.exc.HTTPForbidden(detail='Unauthorized') + + if req.path == vnc.proxy.WS_ENDPOINT: + req.environ['vnc_host'] = connection_info['host'] + req.environ['vnc_port'] = int(connection_info['port']) + + return req.get_response(self.app) + + def get_token_info(self, token): + if token in self.token_cache: + return self.token_cache[token] + + rval = rpc.call(context.get_admin_context(), + FLAGS.vncproxy_topic, + {"method": "check_token", "args": {'token': token}}) + if rval: + self.token_cache[token] = rval + return rval + + def delete_expired_cache_items(self): + now = time.time() + to_delete = [] + for k, v in self.token_cache.items(): + if now - v['last_activity_at'] > FLAGS.vnc_token_ttl: + to_delete.append(k) + + for k in to_delete: + del self.token_cache[k] + + +class LoggingMiddleware(object): + """Middleware for basic vnc-specific request logging.""" + + def __init__(self, app): + self.app = app + + @webob.dec.wsgify + def __call__(self, req): + if req.path == vnc.proxy.WS_ENDPOINT: + LOG.info(_("Received Websocket Request: %s"), req.url) + else: + LOG.info(_("Received Request: %s"), req.url) + + return req.get_response(self.app) + + +class VNCProxyAuthManager(manager.Manager): + """Manages token based authentication.""" + + def __init__(self, scheduler_driver=None, *args, **kwargs): + super(VNCProxyAuthManager, self).__init__(*args, **kwargs) + self.tokens = {} + utils.LoopingCall(self._delete_expired_tokens).start(1) + + def authorize_vnc_console(self, context, token, host, port): + self.tokens[token] = {'host': host, + 'port': port, + 'last_activity_at': time.time()} + token_dict = self.tokens[token] + LOG.audit(_("Received Token: %(token)s, %(token_dict)s)"), locals()) + + def check_token(self, context, token): + token_valid = token in self.tokens + LOG.audit(_("Checking Token: %(token)s, %(token_valid)s)"), locals()) + if token_valid: + return self.tokens[token] + + def _delete_expired_tokens(self): + now = time.time() + to_delete = [] + for k, v in self.tokens.items(): + if now - v['last_activity_at'] > FLAGS.vnc_token_ttl: + to_delete.append(k) + + for k in to_delete: + LOG.audit(_("Deleting Expired Token: %s)"), k) + del self.tokens[k] diff --git a/nova/vnc/proxy.py b/nova/vnc/proxy.py new file mode 100644 index 000000000..c4603803b --- /dev/null +++ b/nova/vnc/proxy.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2010 Openstack, LLC. +# 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. + +"""Eventlet WSGI Services to proxy VNC. No nova deps.""" + +import base64 +import os + +import eventlet +from eventlet import wsgi +from eventlet import websocket + +import webob + + +WS_ENDPOINT = '/data' + + +class WebsocketVNCProxy(object): + """Class to proxy from websocket to vnc server.""" + + def __init__(self, wwwroot): + self.wwwroot = wwwroot + self.whitelist = {} + for root, dirs, files in os.walk(wwwroot): + hidden_dirs = [] + for d in dirs: + if d.startswith('.'): + hidden_dirs.append(d) + for d in hidden_dirs: + dirs.remove(d) + for name in files: + if not str(name).startswith('.'): + filename = os.path.join(root, name) + self.whitelist[filename] = True + + def get_whitelist(self): + return self.whitelist.keys() + + def sock2ws(self, source, dest): + try: + while True: + d = source.recv(32384) + if d == '': + break + d = base64.b64encode(d) + dest.send(d) + except: + source.close() + dest.close() + + def ws2sock(self, source, dest): + try: + while True: + d = source.wait() + if d is None: + break + d = base64.b64decode(d) + dest.sendall(d) + except: + source.close() + dest.close() + + def proxy_connection(self, environ, start_response): + @websocket.WebSocketWSGI + def _handle(client): + server = eventlet.connect((client.environ['vnc_host'], + client.environ['vnc_port'])) + t1 = eventlet.spawn(self.ws2sock, client, server) + t2 = eventlet.spawn(self.sock2ws, server, client) + t1.wait() + t2.wait() + _handle(environ, start_response) + + def __call__(self, environ, start_response): + req = webob.Request(environ) + if req.path == WS_ENDPOINT: + return self.proxy_connection(environ, start_response) + else: + if req.path == '/': + fname = '/vnc_auto.html' + else: + fname = req.path + + fname = (self.wwwroot + fname).replace('//', '/') + if not fname in self.whitelist: + start_response('404 Not Found', + [('content-type', 'text/html')]) + return "Not Found" + + base, ext = os.path.splitext(fname) + if ext == '.js': + mimetype = 'application/javascript' + elif ext == '.css': + mimetype = 'text/css' + elif ext in ['.svg', '.jpg', '.png', '.gif']: + mimetype = 'image' + else: + mimetype = 'text/html' + + start_response('200 OK', [('content-type', mimetype)]) + return open(os.path.join(fname)).read() + + +class DebugMiddleware(object): + """Debug middleware. Skip auth, get vnc connect info from query string.""" + + def __init__(self, app): + self.app = app + + @webob.dec.wsgify + def __call__(self, req): + if req.path == WS_ENDPOINT: + req.environ['vnc_host'] = req.params.get('host') + req.environ['vnc_port'] = int(req.params.get('port')) + return req.get_response(self.app) diff --git a/nova/volume/driver.py b/nova/volume/driver.py index 28d08201b..850893914 100644 --- a/nova/volume/driver.py +++ b/nova/volume/driver.py @@ -135,7 +135,7 @@ class VolumeDriver(object): """Removes an export for a logical volume.""" raise NotImplementedError() - def discover_volume(self, volume): + def discover_volume(self, context, volume): """Discover volume on a remote host.""" raise NotImplementedError() @@ -573,6 +573,8 @@ class RBDDriver(VolumeDriver): def discover_volume(self, volume): """Discover volume on a remote host""" + # NOTE(justinsb): This is messed up... discover_volume takes 3 args + # but then that would break local_path return "rbd:%s/%s" % (FLAGS.rbd_pool, volume['name']) def undiscover_volume(self, volume): @@ -621,10 +623,81 @@ class SheepdogDriver(VolumeDriver): """Removes an export for a logical volume""" pass - def discover_volume(self, volume): + def discover_volume(self, context, volume): """Discover volume on a remote host""" return "sheepdog:%s" % volume['name'] def undiscover_volume(self, volume): """Undiscover volume on a remote host""" pass + + +class LoggingVolumeDriver(VolumeDriver): + """Logs and records calls, for unit tests.""" + + def check_for_setup_error(self): + pass + + def create_volume(self, volume): + self.log_action('create_volume', volume) + + def delete_volume(self, volume): + self.log_action('delete_volume', volume) + + def local_path(self, volume): + print "local_path not implemented" + raise NotImplementedError() + + def ensure_export(self, context, volume): + self.log_action('ensure_export', volume) + + def create_export(self, context, volume): + self.log_action('create_export', volume) + + def remove_export(self, context, volume): + self.log_action('remove_export', volume) + + def discover_volume(self, context, volume): + self.log_action('discover_volume', volume) + + def undiscover_volume(self, volume): + self.log_action('undiscover_volume', volume) + + def check_for_export(self, context, volume_id): + self.log_action('check_for_export', volume_id) + + _LOGS = [] + + @staticmethod + def clear_logs(): + LoggingVolumeDriver._LOGS = [] + + @staticmethod + def log_action(action, parameters): + """Logs the command.""" + LOG.debug(_("LoggingVolumeDriver: %s") % (action)) + log_dictionary = {} + if parameters: + log_dictionary = dict(parameters) + log_dictionary['action'] = action + LOG.debug(_("LoggingVolumeDriver: %s") % (log_dictionary)) + LoggingVolumeDriver._LOGS.append(log_dictionary) + + @staticmethod + def all_logs(): + return LoggingVolumeDriver._LOGS + + @staticmethod + def logs_like(action, **kwargs): + matches = [] + for entry in LoggingVolumeDriver._LOGS: + if entry['action'] != action: + continue + match = True + for k, v in kwargs.iteritems(): + if entry.get(k) != v: + match = False + break + if match: + matches.append(entry) + return matches @@ -112,4 +112,5 @@ DistUtilsExtra.auto.setup(name='nova', 'bin/nova-spoolsentry', 'bin/stack', 'bin/nova-volume', + 'bin/nova-vncproxy', 'tools/nova-debug']) diff --git a/smoketests/test_sysadmin.py b/smoketests/test_sysadmin.py index 9bed1e092..268d9865b 100644 --- a/smoketests/test_sysadmin.py +++ b/smoketests/test_sysadmin.py @@ -266,10 +266,11 @@ class VolumeTests(base.UserSmokeTestCase): ip = self.data['instance'].private_dns_name conn = self.connect_ssh(ip, TEST_KEY) stdin, stdout, stderr = conn.exec_command( - "blockdev --getsize64 %s" % self.device) + "cat /sys/class/block/%s/size" % self.device.rpartition('/')[2]) out = stdout.read().strip() conn.close() - expected_size = 1024 * 1024 * 1024 + # NOTE(vish): 1G bytes / 512 bytes per block + expected_size = 1024 * 1024 * 1024 / 512 self.assertEquals('%s' % (expected_size,), out, 'Volume is not the right size: %s %s. Expected: %s' % (out, stderr.read(), expected_size)) |