diff options
| author | Clay Gerrard <clay.gerrard@gmail.com> | 2012-09-14 16:15:35 +0000 |
|---|---|---|
| committer | Clay Gerrard <clay.gerrard@gmail.com> | 2012-09-14 16:40:03 -0500 |
| commit | 4ebec877303048e8bb0c5ce7f68b587b2972ec3c (patch) | |
| tree | 4a596895409fee1c44134a7dd67be6ff6049929c /nova/tests | |
| parent | d41b93267f2c0e31fa94c8ea185864b631142df4 (diff) | |
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
Diffstat (limited to 'nova/tests')
| -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 |
6 files changed, 244 insertions, 3 deletions
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() |
