summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJustin Santa Barbara <justin@fathomdb.com>2011-03-30 02:46:57 +0000
committerTarmac <>2011-03-30 02:46:57 +0000
commit971be24dd38b576f4cf89a2c7d8856593b93c29d (patch)
tree524af0977e09546d49e28be4888f6ee713bb2d59
parente5f108058f9b085571330dff3c3e3e3e57d2e5ed (diff)
parent9397766990b00167071bca6392096abfb93af982 (diff)
Support for volumes in the OpenStack API
Two new endpoints: /volumes and /servers/<id>/volume_attachments
-rw-r--r--.bzrignore5
-rw-r--r--nova/api/openstack/__init__.py2
-rw-r--r--nova/api/openstack/common.py6
-rw-r--r--nova/api/openstack/contrib/__init__.py22
-rw-r--r--nova/api/openstack/contrib/volumes.py336
-rw-r--r--nova/api/openstack/extensions.py204
-rw-r--r--nova/image/fake.py113
-rw-r--r--nova/tests/integrated/api/client.py40
-rw-r--r--nova/tests/integrated/integrated_helpers.py119
-rw-r--r--nova/tests/integrated/test_extensions.py44
-rw-r--r--nova/tests/integrated/test_login.py21
-rw-r--r--nova/tests/integrated/test_servers.py184
-rw-r--r--nova/tests/integrated/test_volumes.py295
-rw-r--r--nova/virt/driver.py23
-rw-r--r--nova/virt/fake.py20
-rw-r--r--nova/volume/driver.py77
16 files changed, 1386 insertions, 125 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
diff --git a/nova/api/openstack/__init__.py b/nova/api/openstack/__init__.py
index 4f9405075..7545eb0c9 100644
--- a/nova/api/openstack/__init__.py
+++ b/nova/api/openstack/__init__.py
@@ -114,6 +114,8 @@ class APIRouter(wsgi.Router):
_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."""
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/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/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/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..030f0a2fa 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"""
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