controller_exts = []
for ext in self.extensions.values():
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):
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'})
self.resources['limits'] = limits.create_resource()
mapper.resource("limit", "limits",
--- /dev/null
+# 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 cinder.api.openstack import extensions
+from cinder.api.openstack import wsgi
+from cinder import db
+from cinder import exception
+from cinder import volume
+from cinder.openstack.common import log as logging
+
+
+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['cinder.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['cinder.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
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)
return {'snapshot': retval}
-def create_resource():
- return wsgi.Resource(SnapshotsController())
+def create_resource(ext_mgr):
+ return wsgi.Resource(SnapshotsController(ext_mgr))
# 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
--- /dev/null
+import webob
+
+from cinder import context
+from cinder import db
+from cinder import exception
+from cinder import test
+from cinder.openstack.common import jsonutils
+from cinder.tests.api.openstack import fakes
+
+
+def app():
+ # no auth, just let environ['cinder.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', True)
+ # 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['cinder.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['cinder.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', True)
+ # 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['cinder.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', True)
+ # 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['cinder.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', True)
+ # 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['cinder.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', True)
+ # 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['cinder.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', True)
+ # 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['cinder.context'] = ctx
+ resp = req.get_response(app())
+ # request is accepted
+ 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', True)
+ # 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['cinder.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'])
return {}
-def create_resource():
- return wsgi.Resource(FakeController())
-
-
-def create_volume_resource(ext_mgr):
+def create_resource(ext_mgr):
return wsgi.Resource(FakeController(ext_mgr))
super(VolumeRouterTestCase, self).setUp()
# NOTE(vish): versions is just returning text so, no need to stub.
self.stubs.Set(snapshots, 'create_resource', create_resource)
- self.stubs.Set(volumes, 'create_resource', create_volume_resource)
+ self.stubs.Set(volumes, 'create_resource', create_resource)
self.app = volume.APIRouter()
def test_versions(self):
{
+ "admin_api": [["role:admin"]],
+
"volume:create": [],
"volume:get": [],
"volume:get_all": [],
"volume:get_snapshot": [],
"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:volume_actions:upload_image": [],
"volume_extension:types_manage": [],
"volume_extension:types_extra_specs": [],
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 = cinder.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 = cinder.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."""
+ """Test volume can't be deleted with dependent snapshots."""
volume = self._create_volume()
self.volume.create_volume(self.context, volume['id'])
snapshot_id = self._create_snapshot(volume['id'])
"reservations": 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)
"volume_extension:quotas:show": [],
"volume_extension:quotas:update_for_project": [["rule:admin_api"]],
"volume_extension:quotas:update_for_user": [["rule:admin_or_projectadmin"]],
- "volume_extension:quota_classes": []
+ "volume_extension:quota_classes": [],
+ "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"]]
}