summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorClay Gerrard <clay.gerrard@gmail.com>2012-09-14 16:15:35 +0000
committerClay Gerrard <clay.gerrard@gmail.com>2012-09-14 16:40:03 -0500
commit4ebec877303048e8bb0c5ce7f68b587b2972ec3c (patch)
tree4a596895409fee1c44134a7dd67be6ff6049929c
parentd41b93267f2c0e31fa94c8ea185864b631142df4 (diff)
downloadnova-4ebec877303048e8bb0c5ce7f68b587b2972ec3c.tar.gz
nova-4ebec877303048e8bb0c5ce7f68b587b2972ec3c.tar.xz
nova-4ebec877303048e8bb0c5ce7f68b587b2972ec3c.zip
Add admin actions extension
The optional os-admin-actions extension adds new wsgi_actions to the volumes/action resource and a new snapshots/action endpoint. With this extension both controllers will support an os-reset_status action to force a database update of a volume or snapshot that is stuck in a failed/incorrect status. The os-reset_status action works similarly to the compute api's os-reset_state action for instances. The os-force_delete action behaves similarly to the "cinder-manage volume delete" command and allows operators/admins to retry the delete operation after it has gone into an error_deleting status with an admin api call. The os-admin-actions extension is enabled by default, but limited to the admin api by the default policy.json rules. Individual admin actions can be disabled with policy rules as well. Example of os-reset_status action on a volume: curl http://localhost:8776/v1/${PROJECT_ID}/volumes/${VOLUME_ID}/action \ -H "x-auth-token: ${ADMIN_AUTH_TOKEN}" \ -H 'content-type: application/json' \ -d '{"os-reset_status": {"status": "error"}}' The new admin only api can assist deployers who encounter bugs or operational issues that result in failed actions. It can also be used by future storage backends to support async callback style status updates from long running actions or operations which have encountered an error will be retried. Also updates the api.openstack.wsgi.ControllerMetaclass to support sub-classing wsgi.Controllers that define wsgi_actions. Partial fix for bug #1039706 Change-Id: If795599d5150dea362279d75a75276f3166d0149
-rw-r--r--etc/nova/policy.json3
-rw-r--r--nova/api/openstack/extensions.py5
-rw-r--r--nova/api/openstack/volume/__init__.py10
-rw-r--r--nova/api/openstack/volume/contrib/admin_actions.py129
-rw-r--r--nova/api/openstack/volume/snapshots.py7
-rw-r--r--nova/api/openstack/volume/volumes.py7
-rw-r--r--nova/api/openstack/wsgi.py3
-rw-r--r--nova/tests/api/openstack/fakes.py1
-rw-r--r--nova/tests/api/openstack/volume/contrib/test_admin_actions.py184
-rw-r--r--nova/tests/api/openstack/volume/test_router.py7
-rw-r--r--nova/tests/api/openstack/volume/test_volumes.py5
-rw-r--r--nova/tests/policy.json5
-rw-r--r--nova/tests/test_volume.py45
-rw-r--r--nova/volume/api.py4
14 files changed, 398 insertions, 17 deletions
diff --git a/etc/nova/policy.json b/etc/nova/policy.json
index 688918d66..a6936af08 100644
--- a/etc/nova/policy.json
+++ b/etc/nova/policy.json
@@ -76,6 +76,9 @@
"volume_extension:types_manage": [["rule:admin_api"]],
"volume_extension:types_extra_specs": [["rule:admin_api"]],
+ "volume_extension:volume_admin_actions:reset_status": [["rule:admin_api"]],
+ "volume_extension:snapshot_admin_actions:reset_status": [["rule:admin_api"]],
+ "volume_extension:volume_admin_actions:force_delete": [["rule:admin_api"]],
"network:get_all_networks": [],
diff --git a/nova/api/openstack/extensions.py b/nova/api/openstack/extensions.py
index e7334123a..f36586443 100644
--- a/nova/api/openstack/extensions.py
+++ b/nova/api/openstack/extensions.py
@@ -223,11 +223,12 @@ class ExtensionManager(object):
controller_exts = []
for ext in self.sorted_extensions():
try:
- controller_exts.extend(ext.get_controller_extensions())
+ get_ext_method = ext.get_controller_extensions
except AttributeError:
# NOTE(Vek): Extensions aren't required to have
# controller extensions
- pass
+ continue
+ controller_exts.extend(get_ext_method())
return controller_exts
def _check_extension(self, extension):
diff --git a/nova/api/openstack/volume/__init__.py b/nova/api/openstack/volume/__init__.py
index 092ce6c5d..cc161416c 100644
--- a/nova/api/openstack/volume/__init__.py
+++ b/nova/api/openstack/volume/__init__.py
@@ -47,16 +47,18 @@ class APIRouter(nova.api.openstack.APIRouter):
mapper.redirect("", "/")
- self.resources['volumes'] = volumes.create_resource()
+ self.resources['volumes'] = volumes.create_resource(ext_mgr)
mapper.resource("volume", "volumes",
controller=self.resources['volumes'],
- collection={'detail': 'GET'})
+ collection={'detail': 'GET'},
+ member={'action': 'POST'})
self.resources['types'] = types.create_resource()
mapper.resource("type", "types",
controller=self.resources['types'])
- self.resources['snapshots'] = snapshots.create_resource()
+ self.resources['snapshots'] = snapshots.create_resource(ext_mgr)
mapper.resource("snapshot", "snapshots",
controller=self.resources['snapshots'],
- collection={'detail': 'GET'})
+ collection={'detail': 'GET'},
+ member={'action': 'POST'})
diff --git a/nova/api/openstack/volume/contrib/admin_actions.py b/nova/api/openstack/volume/contrib/admin_actions.py
new file mode 100644
index 000000000..7e93283f7
--- /dev/null
+++ b/nova/api/openstack/volume/contrib/admin_actions.py
@@ -0,0 +1,129 @@
+# Copyright 2012 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.
+
+import webob
+from webob import exc
+
+from nova.api.openstack import extensions
+from nova.api.openstack import wsgi
+from nova import db
+from nova import exception
+from nova.openstack.common import log as logging
+from nova import volume
+
+
+LOG = logging.getLogger(__name__)
+
+
+class AdminController(wsgi.Controller):
+ """Abstract base class for AdminControllers."""
+
+ collection = None # api collection to extend
+
+ # FIXME(clayg): this will be hard to keep up-to-date
+ # Concrete classes can expand or over-ride
+ valid_status = set([
+ 'creating',
+ 'available',
+ 'deleting',
+ 'error',
+ 'error_deleting',
+ ])
+
+ def __init__(self, *args, **kwargs):
+ super(AdminController, self).__init__(*args, **kwargs)
+ # singular name of the resource
+ self.resource_name = self.collection.rstrip('s')
+ self.volume_api = volume.API()
+
+ def _update(self, *args, **kwargs):
+ raise NotImplementedError()
+
+ def _validate_status(self, status):
+ if status not in self.valid_status:
+ raise exc.HTTPBadRequest("Must specify a valid status")
+
+ def authorize(self, context, action_name):
+ # e.g. "snapshot_admin_actions:reset_status"
+ action = '%s_admin_actions:%s' % (self.resource_name, action_name)
+ extensions.extension_authorizer('volume', action)(context)
+
+ @wsgi.action('os-reset_status')
+ def _reset_status(self, req, id, body):
+ """Reset status on the resource."""
+ context = req.environ['nova.context']
+ self.authorize(context, 'reset_status')
+ try:
+ new_status = body['os-reset_status']['status']
+ except (TypeError, KeyError):
+ raise exc.HTTPBadRequest("Must specify 'status'")
+ self._validate_status(new_status)
+ msg = _("Updating status of %(resource)s '%(id)s' to '%(status)s'")
+ LOG.debug(msg, {'resource': self.resource_name, 'id': id,
+ 'status': new_status})
+ try:
+ self._update(context, id, {'status': new_status})
+ except exception.NotFound, e:
+ raise exc.HTTPNotFound(e)
+ return webob.Response(status_int=202)
+
+
+class VolumeAdminController(AdminController):
+ """AdminController for Volumes."""
+
+ collection = 'volumes'
+ valid_status = AdminController.valid_status.union(
+ set(['attaching', 'in-use', 'detaching']))
+
+ def _update(self, *args, **kwargs):
+ db.volume_update(*args, **kwargs)
+
+ @wsgi.action('os-force_delete')
+ def _force_delete(self, req, id, body):
+ """Delete a resource, bypassing the check that it must be available."""
+ context = req.environ['nova.context']
+ self.authorize(context, 'force_delete')
+ try:
+ volume = self.volume_api.get(context, id)
+ except exception.NotFound:
+ raise exc.HTTPNotFound()
+ self.volume_api.delete(context, volume, force=True)
+ return webob.Response(status_int=202)
+
+
+class SnapshotAdminController(AdminController):
+ """AdminController for Snapshots."""
+
+ collection = 'snapshots'
+
+ def _update(self, *args, **kwargs):
+ db.snapshot_update(*args, **kwargs)
+
+
+class Admin_actions(extensions.ExtensionDescriptor):
+ """Enable admin actions."""
+
+ name = "AdminActions"
+ alias = "os-admin-actions"
+ namespace = "http://docs.openstack.org/volume/ext/admin-actions/api/v1.1"
+ updated = "2012-08-25T00:00:00+00:00"
+
+ def get_controller_extensions(self):
+ exts = []
+ for class_ in (VolumeAdminController, SnapshotAdminController):
+ controller = class_()
+ extension = extensions.ControllerExtension(
+ self, class_.collection, controller)
+ exts.append(extension)
+ return exts
diff --git a/nova/api/openstack/volume/snapshots.py b/nova/api/openstack/volume/snapshots.py
index 755398369..14c14cf86 100644
--- a/nova/api/openstack/volume/snapshots.py
+++ b/nova/api/openstack/volume/snapshots.py
@@ -88,8 +88,9 @@ class SnapshotsTemplate(xmlutil.TemplateBuilder):
class SnapshotsController(object):
"""The Volumes API controller for the OpenStack API."""
- def __init__(self):
+ def __init__(self, ext_mgr=None):
self.volume_api = volume.API()
+ self.ext_mgr = ext_mgr
super(SnapshotsController, self).__init__()
@wsgi.serializers(xml=SnapshotTemplate)
@@ -175,5 +176,5 @@ class SnapshotsController(object):
return {'snapshot': retval}
-def create_resource():
- return wsgi.Resource(SnapshotsController())
+def create_resource(ext_mgr):
+ return wsgi.Resource(SnapshotsController(ext_mgr))
diff --git a/nova/api/openstack/volume/volumes.py b/nova/api/openstack/volume/volumes.py
index e83030945..be5b5bd83 100644
--- a/nova/api/openstack/volume/volumes.py
+++ b/nova/api/openstack/volume/volumes.py
@@ -195,8 +195,9 @@ class CreateDeserializer(CommonDeserializer):
class VolumeController(object):
"""The Volumes API controller for the OpenStack API."""
- def __init__(self):
+ def __init__(self, ext_mgr=None):
self.volume_api = volume.API()
+ self.ext_mgr = ext_mgr
super(VolumeController, self).__init__()
@wsgi.serializers(xml=VolumeTemplate)
@@ -309,8 +310,8 @@ class VolumeController(object):
return ('name', 'status')
-def create_resource():
- return wsgi.Resource(VolumeController())
+def create_resource(ext_mgr):
+ return wsgi.Resource(VolumeController(ext_mgr))
def remove_invalid_options(context, search_options, allowed_search_options):
diff --git a/nova/api/openstack/wsgi.py b/nova/api/openstack/wsgi.py
index 658b59645..571f91e5b 100644
--- a/nova/api/openstack/wsgi.py
+++ b/nova/api/openstack/wsgi.py
@@ -1070,6 +1070,9 @@ class ControllerMetaclass(type):
# Find all actions
actions = {}
extensions = []
+ # start with wsgi actions from base classes
+ for base in bases:
+ actions.update(getattr(base, 'wsgi_actions', {}))
for key, value in cls_dict.items():
if not callable(value):
continue
diff --git a/nova/tests/api/openstack/fakes.py b/nova/tests/api/openstack/fakes.py
index fd3ffa2fc..6a6f85f68 100644
--- a/nova/tests/api/openstack/fakes.py
+++ b/nova/tests/api/openstack/fakes.py
@@ -30,6 +30,7 @@ from nova.api.openstack import compute
from nova.api.openstack.compute import limits
from nova.api.openstack.compute import versions
from nova.api.openstack import urlmap
+from nova.api.openstack import volume
from nova.api.openstack import wsgi as os_wsgi
from nova.compute import instance_types
from nova.compute import vm_states
diff --git a/nova/tests/api/openstack/volume/contrib/test_admin_actions.py b/nova/tests/api/openstack/volume/contrib/test_admin_actions.py
new file mode 100644
index 000000000..fe8c85c32
--- /dev/null
+++ b/nova/tests/api/openstack/volume/contrib/test_admin_actions.py
@@ -0,0 +1,184 @@
+import webob
+
+from nova import context
+from nova import db
+from nova import exception
+from nova.openstack.common import jsonutils
+from nova import test
+from nova.tests.api.openstack import fakes
+
+
+def app():
+ # no auth, just let environ['nova.context'] pass through
+ api = fakes.volume.APIRouter()
+ mapper = fakes.urlmap.URLMap()
+ mapper['/v1'] = api
+ return mapper
+
+
+class AdminActionsTest(test.TestCase):
+
+ def test_reset_status_as_admin(self):
+ # admin context
+ ctx = context.RequestContext('admin', 'fake', is_admin=True)
+ ctx.elevated() # add roles
+ # current status is available
+ volume = db.volume_create(ctx, {'status': 'available'})
+ req = webob.Request.blank('/v1/fake/volumes/%s/action' % volume['id'])
+ req.method = 'POST'
+ req.headers['content-type'] = 'application/json'
+ # request status of 'error'
+ req.body = jsonutils.dumps({'os-reset_status': {'status': 'error'}})
+ # attach admin context to request
+ req.environ['nova.context'] = ctx
+ resp = req.get_response(app())
+ # request is accepted
+ self.assertEquals(resp.status_int, 202)
+ volume = db.volume_get(ctx, volume['id'])
+ # status changed to 'error'
+ self.assertEquals(volume['status'], 'error')
+
+ def test_reset_status_as_non_admin(self):
+ # current status is 'error'
+ volume = db.volume_create(context.get_admin_context(),
+ {'status': 'error'})
+ req = webob.Request.blank('/v1/fake/volumes/%s/action' % volume['id'])
+ req.method = 'POST'
+ req.headers['content-type'] = 'application/json'
+ # request changing status to available
+ req.body = jsonutils.dumps({'os-reset_status': {'status':
+ 'available'}})
+ # non-admin context
+ req.environ['nova.context'] = context.RequestContext('fake', 'fake')
+ resp = req.get_response(app())
+ # request is not authorized
+ self.assertEquals(resp.status_int, 403)
+ volume = db.volume_get(context.get_admin_context(), volume['id'])
+ # status is still 'error'
+ self.assertEquals(volume['status'], 'error')
+
+ def test_malformed_reset_status_body(self):
+ # admin context
+ ctx = context.RequestContext('admin', 'fake', is_admin=True)
+ ctx.elevated() # add roles
+ # current status is available
+ volume = db.volume_create(ctx, {'status': 'available'})
+ req = webob.Request.blank('/v1/fake/volumes/%s/action' % volume['id'])
+ req.method = 'POST'
+ req.headers['content-type'] = 'application/json'
+ # malformed request body
+ req.body = jsonutils.dumps({'os-reset_status': {'x-status': 'bad'}})
+ # attach admin context to request
+ req.environ['nova.context'] = ctx
+ resp = req.get_response(app())
+ # bad request
+ self.assertEquals(resp.status_int, 400)
+ volume = db.volume_get(ctx, volume['id'])
+ # status is still 'available'
+ self.assertEquals(volume['status'], 'available')
+
+ def test_invalid_status_for_volume(self):
+ # admin context
+ ctx = context.RequestContext('admin', 'fake', is_admin=True)
+ ctx.elevated() # add roles
+ # current status is available
+ volume = db.volume_create(ctx, {'status': 'available'})
+ req = webob.Request.blank('/v1/fake/volumes/%s/action' % volume['id'])
+ req.method = 'POST'
+ req.headers['content-type'] = 'application/json'
+ # 'invalid' is not a valid status
+ req.body = jsonutils.dumps({'os-reset_status': {'status': 'invalid'}})
+ # attach admin context to request
+ req.environ['nova.context'] = ctx
+ resp = req.get_response(app())
+ # bad request
+ self.assertEquals(resp.status_int, 400)
+ volume = db.volume_get(ctx, volume['id'])
+ # status is still 'available'
+ self.assertEquals(volume['status'], 'available')
+
+ def test_reset_status_for_missing_volume(self):
+ # admin context
+ ctx = context.RequestContext('admin', 'fake', is_admin=True)
+ ctx.elevated() # add roles
+ # missing-volume-id
+ req = webob.Request.blank('/v1/fake/volumes/%s/action' %
+ 'missing-volume-id')
+ req.method = 'POST'
+ req.headers['content-type'] = 'application/json'
+ # malformed request body
+ req.body = jsonutils.dumps({'os-reset_status': {'status':
+ 'available'}})
+ # attach admin context to request
+ req.environ['nova.context'] = ctx
+ resp = req.get_response(app())
+ # not found
+ self.assertEquals(resp.status_int, 404)
+ self.assertRaises(exception.NotFound, db.volume_get, ctx,
+ 'missing-volume-id')
+
+ def test_snapshot_reset_status(self):
+ # admin context
+ ctx = context.RequestContext('admin', 'fake', is_admin=True)
+ ctx.elevated() # add roles
+ # snapshot in 'error_deleting'
+ volume = db.volume_create(ctx, {})
+ snapshot = db.snapshot_create(ctx, {'status': 'error_deleting',
+ 'volume_id': volume['id']})
+ req = webob.Request.blank('/v1/fake/snapshots/%s/action' %
+ snapshot['id'])
+ req.method = 'POST'
+ req.headers['content-type'] = 'application/json'
+ # request status of 'error'
+ req.body = jsonutils.dumps({'os-reset_status': {'status': 'error'}})
+ # attach admin context to request
+ req.environ['nova.context'] = ctx
+ resp = req.get_response(app())
+ # request is accepted
+ self.assertEquals(resp.status_int, 202)
+ snapshot = db.snapshot_get(ctx, snapshot['id'])
+ # status changed to 'error'
+ self.assertEquals(snapshot['status'], 'error')
+
+ def test_invalid_status_for_snapshot(self):
+ # admin context
+ ctx = context.RequestContext('admin', 'fake', is_admin=True)
+ ctx.elevated() # add roles
+ # snapshot in 'available'
+ volume = db.volume_create(ctx, {})
+ snapshot = db.snapshot_create(ctx, {'status': 'available',
+ 'volume_id': volume['id']})
+ req = webob.Request.blank('/v1/fake/snapshots/%s/action' %
+ snapshot['id'])
+ req.method = 'POST'
+ req.headers['content-type'] = 'application/json'
+ # 'attaching' is not a valid status for snapshots
+ req.body = jsonutils.dumps({'os-reset_status': {'status':
+ 'attaching'}})
+ # attach admin context to request
+ req.environ['nova.context'] = ctx
+ resp = req.get_response(app())
+ # request is accepted
+ print resp
+ self.assertEquals(resp.status_int, 400)
+ snapshot = db.snapshot_get(ctx, snapshot['id'])
+ # status is still 'available'
+ self.assertEquals(snapshot['status'], 'available')
+
+ def test_force_delete(self):
+ # admin context
+ ctx = context.RequestContext('admin', 'fake', is_admin=True)
+ ctx.elevated() # add roles
+ # current status is creating
+ volume = db.volume_create(ctx, {'status': 'creating'})
+ req = webob.Request.blank('/v1/fake/volumes/%s/action' % volume['id'])
+ req.method = 'POST'
+ req.headers['content-type'] = 'application/json'
+ req.body = jsonutils.dumps({'os-force_delete': {}})
+ # attach admin context to request
+ req.environ['nova.context'] = ctx
+ resp = req.get_response(app())
+ # request is accepted
+ self.assertEquals(resp.status_int, 202)
+ # volume is deleted
+ self.assertRaises(exception.NotFound, db.volume_get, ctx, volume['id'])
diff --git a/nova/tests/api/openstack/volume/test_router.py b/nova/tests/api/openstack/volume/test_router.py
index 677cddb6a..97900bd6d 100644
--- a/nova/tests/api/openstack/volume/test_router.py
+++ b/nova/tests/api/openstack/volume/test_router.py
@@ -30,6 +30,9 @@ LOG = logging.getLogger(__name__)
class FakeController(object):
+ def __init__(self, ext_mgr=None):
+ self.ext_mgr = ext_mgr
+
def index(self, req):
return {}
@@ -37,8 +40,8 @@ class FakeController(object):
return {}
-def create_resource():
- return wsgi.Resource(FakeController())
+def create_resource(ext_mgr):
+ return wsgi.Resource(FakeController(ext_mgr))
class VolumeRouterTestCase(test.TestCase):
diff --git a/nova/tests/api/openstack/volume/test_volumes.py b/nova/tests/api/openstack/volume/test_volumes.py
index 46a772fbd..8c458e53c 100644
--- a/nova/tests/api/openstack/volume/test_volumes.py
+++ b/nova/tests/api/openstack/volume/test_volumes.py
@@ -18,6 +18,7 @@ import datetime
from lxml import etree
import webob
+from nova.api.openstack.volume import extensions
from nova.api.openstack.volume import volumes
from nova import db
from nova import exception
@@ -34,7 +35,9 @@ FLAGS = flags.FLAGS
class VolumeApiTest(test.TestCase):
def setUp(self):
super(VolumeApiTest, self).setUp()
- self.controller = volumes.VolumeController()
+ self.ext_mgr = extensions.ExtensionManager()
+ self.ext_mgr.extensions = {}
+ self.controller = volumes.VolumeController(self.ext_mgr)
self.stubs.Set(db, 'volume_get_all', fakes.stub_volume_get_all)
self.stubs.Set(db, 'volume_get_all_by_project',
diff --git a/nova/tests/policy.json b/nova/tests/policy.json
index d735f6b86..a6330c9e5 100644
--- a/nova/tests/policy.json
+++ b/nova/tests/policy.json
@@ -1,4 +1,6 @@
{
+ "admin_api": [["role:admin"]],
+
"context_is_admin": [["role:admin"], ["role:administrator"]],
"compute:create": [],
"compute:create:attach_network": [],
@@ -149,6 +151,9 @@
"volume:get_all_snapshots": [],
+ "volume_extension:volume_admin_actions:reset_status": [["rule:admin_api"]],
+ "volume_extension:snapshot_admin_actions:reset_status": [["rule:admin_api"]],
+ "volume_extension:volume_admin_actions:force_delete": [["rule:admin_api"]],
"volume_extension:types_manage": [],
"volume_extension:types_extra_specs": [],
diff --git a/nova/tests/test_volume.py b/nova/tests/test_volume.py
index 5bd510406..63f7d8308 100644
--- a/nova/tests/test_volume.py
+++ b/nova/tests/test_volume.py
@@ -322,6 +322,51 @@ class VolumeTestCase(test.TestCase):
snapshot_id)
self.volume.delete_volume(self.context, volume['id'])
+ def test_cant_delete_volume_in_use(self):
+ """Test volume can't be deleted in invalid stats."""
+ # create a volume and assign to host
+ volume = self._create_volume()
+ self.volume.create_volume(self.context, volume['id'])
+ volume['status'] = 'in-use'
+ volume['host'] = 'fakehost'
+
+ volume_api = nova.volume.api.API()
+
+ # 'in-use' status raises InvalidVolume
+ self.assertRaises(exception.InvalidVolume,
+ volume_api.delete,
+ self.context,
+ volume)
+
+ # clean up
+ self.volume.delete_volume(self.context, volume['id'])
+
+ def test_force_delete_volume(self):
+ """Test volume can be forced to delete."""
+ # create a volume and assign to host
+ volume = self._create_volume()
+ self.volume.create_volume(self.context, volume['id'])
+ volume['status'] = 'error_deleting'
+ volume['host'] = 'fakehost'
+
+ volume_api = nova.volume.api.API()
+
+ # 'error_deleting' volumes can't be deleted
+ self.assertRaises(exception.InvalidVolume,
+ volume_api.delete,
+ self.context,
+ volume)
+
+ # delete with force
+ volume_api.delete(self.context, volume, force=True)
+
+ # status is deleting
+ volume = db.volume_get(context.get_admin_context(), volume['id'])
+ self.assertEquals(volume['status'], 'deleting')
+
+ # clean up
+ self.volume.delete_volume(self.context, volume['id'])
+
def test_cant_delete_volume_with_snapshots(self):
"""Test snapshot can be created and deleted."""
volume = self._create_volume()
diff --git a/nova/volume/api.py b/nova/volume/api.py
index ab93853bc..0c18e4a83 100644
--- a/nova/volume/api.py
+++ b/nova/volume/api.py
@@ -182,13 +182,13 @@ class API(base.Base):
context, volume_id, snapshot_id, reservations)
@wrap_check_policy
- def delete(self, context, volume):
+ def delete(self, context, volume, force=False):
volume_id = volume['id']
if not volume['host']:
# NOTE(vish): scheduling failed, so delete it
self.db.volume_destroy(context, volume_id)
return
- if volume['status'] not in ["available", "error"]:
+ if not force and volume['status'] not in ["available", "error"]:
msg = _("Volume status must be available or error")
raise exception.InvalidVolume(reason=msg)