summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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)