diff options
-rw-r--r-- | etc/nova/policy.json | 3 | ||||
-rw-r--r-- | nova/api/openstack/extensions.py | 5 | ||||
-rw-r--r-- | nova/api/openstack/volume/__init__.py | 10 | ||||
-rw-r--r-- | nova/api/openstack/volume/contrib/admin_actions.py | 129 | ||||
-rw-r--r-- | nova/api/openstack/volume/snapshots.py | 7 | ||||
-rw-r--r-- | nova/api/openstack/volume/volumes.py | 7 | ||||
-rw-r--r-- | nova/api/openstack/wsgi.py | 3 | ||||
-rw-r--r-- | nova/tests/api/openstack/fakes.py | 1 | ||||
-rw-r--r-- | nova/tests/api/openstack/volume/contrib/test_admin_actions.py | 184 | ||||
-rw-r--r-- | nova/tests/api/openstack/volume/test_router.py | 7 | ||||
-rw-r--r-- | nova/tests/api/openstack/volume/test_volumes.py | 5 | ||||
-rw-r--r-- | nova/tests/policy.json | 5 | ||||
-rw-r--r-- | nova/tests/test_volume.py | 45 | ||||
-rw-r--r-- | nova/volume/api.py | 4 |
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) |