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
This commit is contained in:
Clay Gerrard
2012-09-14 16:15:35 +00:00
parent d41b93267f
commit 4ebec87730
14 changed files with 398 additions and 17 deletions

View File

@@ -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": [],

View File

@@ -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):

View File

@@ -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'})

View File

@@ -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

View File

@@ -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))

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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'])

View File

@@ -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):

View File

@@ -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',

View File

@@ -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": [],

View File

@@ -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()

View File

@@ -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)