Delete volumes with snapshots
This adds the 'cascade' parameter to volume delete, which deletes snapshots along with a volume in one call. This is done in the volume manager, and not in a driver-optimized way, which will be a later improvement. Blueprint: del-vols-with-snaps Change-Id: I33d15b76d4bd0de14c635d404b2c97096c977a58
This commit is contained in:
parent
d46fbf0e13
commit
bf2b6b9d90
@ -186,11 +186,13 @@ class VolumeController(wsgi.Controller):
|
|||||||
"""Delete a volume."""
|
"""Delete a volume."""
|
||||||
context = req.environ['cinder.context']
|
context = req.environ['cinder.context']
|
||||||
|
|
||||||
|
cascade = utils.get_bool_param('cascade', req.params)
|
||||||
|
|
||||||
LOG.info(_LI("Delete volume with id: %s"), id, context=context)
|
LOG.info(_LI("Delete volume with id: %s"), id, context=context)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
volume = self.volume_api.get(context, id)
|
volume = self.volume_api.get(context, id)
|
||||||
self.volume_api.delete(context, volume)
|
self.volume_api.delete(context, volume, cascade=cascade)
|
||||||
except exception.VolumeNotFound as error:
|
except exception.VolumeNotFound as error:
|
||||||
raise exc.HTTPNotFound(explanation=error.msg)
|
raise exc.HTTPNotFound(explanation=error.msg)
|
||||||
return webob.Response(status_int=202)
|
return webob.Response(status_int=202)
|
||||||
|
@ -255,6 +255,10 @@ def volume_has_snapshots_filter():
|
|||||||
return IMPL.volume_has_snapshots_filter()
|
return IMPL.volume_has_snapshots_filter()
|
||||||
|
|
||||||
|
|
||||||
|
def volume_has_undeletable_snapshots_filter():
|
||||||
|
return IMPL.volume_has_undeletable_snapshots_filter()
|
||||||
|
|
||||||
|
|
||||||
def volume_has_attachments_filter():
|
def volume_has_attachments_filter():
|
||||||
return IMPL.volume_has_attachments_filter()
|
return IMPL.volume_has_attachments_filter()
|
||||||
|
|
||||||
|
@ -1831,6 +1831,15 @@ def volume_has_snapshots_filter():
|
|||||||
~models.Snapshot.deleted))
|
~models.Snapshot.deleted))
|
||||||
|
|
||||||
|
|
||||||
|
def volume_has_undeletable_snapshots_filter():
|
||||||
|
deletable_statuses = ['available', 'error']
|
||||||
|
return sql.exists().where(
|
||||||
|
and_(models.Volume.id == models.Snapshot.volume_id,
|
||||||
|
~models.Snapshot.deleted,
|
||||||
|
or_(models.Snapshot.cgsnapshot_id != None, # noqa: != None
|
||||||
|
models.Snapshot.status.notin_(deletable_statuses))))
|
||||||
|
|
||||||
|
|
||||||
def volume_has_attachments_filter():
|
def volume_has_attachments_filter():
|
||||||
return sql.exists().where(
|
return sql.exists().where(
|
||||||
and_(models.Volume.id == models.VolumeAttachment.volume_id,
|
and_(models.Volume.id == models.VolumeAttachment.volume_id,
|
||||||
|
@ -65,7 +65,7 @@ class VolumeUnmanageTest(test.TestCase):
|
|||||||
res = self._get_resp(vol.id)
|
res = self._get_resp(vol.id)
|
||||||
self.assertEqual(202, res.status_int, res)
|
self.assertEqual(202, res.status_int, res)
|
||||||
|
|
||||||
mock_rpcapi.assert_called_once_with(self.ctxt, mock.ANY, True)
|
mock_rpcapi.assert_called_once_with(self.ctxt, mock.ANY, True, False)
|
||||||
vol = objects.volume.Volume.get_by_id(self.ctxt, vol.id)
|
vol = objects.volume.Volume.get_by_id(self.ctxt, vol.id)
|
||||||
self.assertEqual('deleting', vol.status)
|
self.assertEqual('deleting', vol.status)
|
||||||
db.volume_destroy(self.ctxt, vol.id)
|
db.volume_destroy(self.ctxt, vol.id)
|
||||||
|
@ -1283,7 +1283,8 @@ class VolumeApiTest(test.TestCase):
|
|||||||
self.assertEqual(202, resp.status_int)
|
self.assertEqual(202, resp.status_int)
|
||||||
|
|
||||||
def test_volume_delete_attached(self):
|
def test_volume_delete_attached(self):
|
||||||
def stub_volume_attached(self, context, volume, force=False):
|
def stub_volume_attached(self, context, volume,
|
||||||
|
force=False, cascade=False):
|
||||||
raise exception.VolumeAttached(volume_id=volume['id'])
|
raise exception.VolumeAttached(volume_id=volume['id'])
|
||||||
self.stubs.Set(volume_api.API, "delete", stub_volume_attached)
|
self.stubs.Set(volume_api.API, "delete", stub_volume_attached)
|
||||||
self.stubs.Set(volume_api.API, 'get', stubs.stub_volume_get)
|
self.stubs.Set(volume_api.API, 'get', stubs.stub_volume_get)
|
||||||
|
@ -4298,6 +4298,49 @@ class VolumeTestCase(BaseVolumeTestCase):
|
|||||||
None,
|
None,
|
||||||
connector)
|
connector)
|
||||||
|
|
||||||
|
def test_cascade_delete_volume_with_snapshots(self):
|
||||||
|
"""Test volume deletion with dependent snapshots."""
|
||||||
|
volume = tests_utils.create_volume(self.context, **self.volume_params)
|
||||||
|
self.volume.create_volume(self.context, volume['id'])
|
||||||
|
snapshot = self._create_snapshot(volume['id'], size=volume['size'])
|
||||||
|
self.volume.create_snapshot(self.context, volume['id'], snapshot)
|
||||||
|
self.assertEqual(
|
||||||
|
snapshot.id, objects.Snapshot.get_by_id(self.context,
|
||||||
|
snapshot.id).id)
|
||||||
|
|
||||||
|
volume['status'] = 'available'
|
||||||
|
volume['host'] = 'fakehost'
|
||||||
|
|
||||||
|
volume_api = cinder.volume.api.API()
|
||||||
|
|
||||||
|
volume_api.delete(self.context,
|
||||||
|
volume,
|
||||||
|
cascade=True)
|
||||||
|
|
||||||
|
def test_cascade_delete_volume_with_snapshots_error(self):
|
||||||
|
"""Test volume deletion with dependent snapshots."""
|
||||||
|
volume = tests_utils.create_volume(self.context, **self.volume_params)
|
||||||
|
self.volume.create_volume(self.context, volume['id'])
|
||||||
|
snapshot = self._create_snapshot(volume['id'], size=volume['size'])
|
||||||
|
self.volume.create_snapshot(self.context, volume['id'], snapshot)
|
||||||
|
self.assertEqual(
|
||||||
|
snapshot.id, objects.Snapshot.get_by_id(self.context,
|
||||||
|
snapshot.id).id)
|
||||||
|
|
||||||
|
snapshot.update({'status': 'in-use'})
|
||||||
|
snapshot.save()
|
||||||
|
|
||||||
|
volume['status'] = 'available'
|
||||||
|
volume['host'] = 'fakehost'
|
||||||
|
|
||||||
|
volume_api = cinder.volume.api.API()
|
||||||
|
|
||||||
|
self.assertRaises(exception.InvalidVolume,
|
||||||
|
volume_api.delete,
|
||||||
|
self.context,
|
||||||
|
volume,
|
||||||
|
cascade=True)
|
||||||
|
|
||||||
|
|
||||||
@ddt.ddt
|
@ddt.ddt
|
||||||
class VolumeMigrationTestCase(VolumeTestCase):
|
class VolumeMigrationTestCase(VolumeTestCase):
|
||||||
|
@ -291,8 +291,9 @@ class VolumeRpcAPITestCase(test.TestCase):
|
|||||||
rpc_method='cast',
|
rpc_method='cast',
|
||||||
volume=self.fake_volume_obj,
|
volume=self.fake_volume_obj,
|
||||||
unmanage_only=False,
|
unmanage_only=False,
|
||||||
version='1.33')
|
cascade=False,
|
||||||
can_send_version.assert_called_once_with('1.33')
|
version='1.40')
|
||||||
|
can_send_version.assert_any_call('1.40')
|
||||||
|
|
||||||
@mock.patch('oslo_messaging.RPCClient.can_send_version',
|
@mock.patch('oslo_messaging.RPCClient.can_send_version',
|
||||||
return_value=False)
|
return_value=False)
|
||||||
@ -302,7 +303,19 @@ class VolumeRpcAPITestCase(test.TestCase):
|
|||||||
volume=self.fake_volume_obj,
|
volume=self.fake_volume_obj,
|
||||||
unmanage_only=False,
|
unmanage_only=False,
|
||||||
version='1.15')
|
version='1.15')
|
||||||
can_send_version.assert_called_once_with('1.33')
|
can_send_version.assert_any_call('1.33')
|
||||||
|
|
||||||
|
@mock.patch('oslo_messaging.RPCClient.can_send_version',
|
||||||
|
return_value=True)
|
||||||
|
def test_delete_volume_cascade(self, can_send_version):
|
||||||
|
self._test_volume_api('delete_volume',
|
||||||
|
rpc_method='cast',
|
||||||
|
volume=self.fake_volume_obj,
|
||||||
|
unmanage_only=False,
|
||||||
|
cascade=True,
|
||||||
|
version='1.40')
|
||||||
|
can_send_version.assert_any_call('1.33')
|
||||||
|
can_send_version.assert_any_call('1.40')
|
||||||
|
|
||||||
def test_create_snapshot(self):
|
def test_create_snapshot(self):
|
||||||
self._test_volume_api('create_snapshot',
|
self._test_volume_api('create_snapshot',
|
||||||
|
@ -329,7 +329,10 @@ class API(base.Base):
|
|||||||
return vref
|
return vref
|
||||||
|
|
||||||
@wrap_check_policy
|
@wrap_check_policy
|
||||||
def delete(self, context, volume, force=False, unmanage_only=False):
|
def delete(self, context, volume,
|
||||||
|
force=False,
|
||||||
|
unmanage_only=False,
|
||||||
|
cascade=False):
|
||||||
if context.is_admin and context.project_id != volume.project_id:
|
if context.is_admin and context.project_id != volume.project_id:
|
||||||
project_id = volume.project_id
|
project_id = volume.project_id
|
||||||
else:
|
else:
|
||||||
@ -374,7 +377,11 @@ class API(base.Base):
|
|||||||
expected['status'] = ('available', 'error', 'error_restoring',
|
expected['status'] = ('available', 'error', 'error_restoring',
|
||||||
'error_extending')
|
'error_extending')
|
||||||
|
|
||||||
# Volume cannot have snapshots if we want to delete it
|
if cascade:
|
||||||
|
# Allow deletion if all snapshots are in an expected state
|
||||||
|
filters = [~db.volume_has_undeletable_snapshots_filter()]
|
||||||
|
else:
|
||||||
|
# Don't allow deletion of volume with snapshots
|
||||||
filters = [~db.volume_has_snapshots_filter()]
|
filters = [~db.volume_has_snapshots_filter()]
|
||||||
values = {'status': 'deleting', 'terminated_at': timeutils.utcnow()}
|
values = {'status': 'deleting', 'terminated_at': timeutils.utcnow()}
|
||||||
|
|
||||||
@ -388,6 +395,22 @@ class API(base.Base):
|
|||||||
LOG.info(msg)
|
LOG.info(msg)
|
||||||
raise exception.InvalidVolume(reason=msg)
|
raise exception.InvalidVolume(reason=msg)
|
||||||
|
|
||||||
|
if cascade:
|
||||||
|
values = {'status': 'deleting'}
|
||||||
|
expected = {'status': ('available', 'error', 'deleting'),
|
||||||
|
'cgsnapshot_id': None}
|
||||||
|
snapshots = objects.snapshot.SnapshotList.get_all_for_volume(
|
||||||
|
context, volume.id)
|
||||||
|
for s in snapshots:
|
||||||
|
result = s.conditional_update(values, expected, filters)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
volume.update({'status': 'error_deleting'})
|
||||||
|
volume.save()
|
||||||
|
|
||||||
|
msg = _('Failed to update snapshot.')
|
||||||
|
raise exception.InvalidVolume(reason=msg)
|
||||||
|
|
||||||
cache = image_cache.ImageVolumeCache(self.db, self)
|
cache = image_cache.ImageVolumeCache(self.db, self)
|
||||||
entry = cache.get_by_image_volume(context, volume.id)
|
entry = cache.get_by_image_volume(context, volume.id)
|
||||||
if entry:
|
if entry:
|
||||||
@ -404,7 +427,10 @@ class API(base.Base):
|
|||||||
msg = _("Unable to delete encrypted volume: %s.") % e.msg
|
msg = _("Unable to delete encrypted volume: %s.") % e.msg
|
||||||
raise exception.InvalidVolume(reason=msg)
|
raise exception.InvalidVolume(reason=msg)
|
||||||
|
|
||||||
self.volume_rpcapi.delete_volume(context, volume, unmanage_only)
|
self.volume_rpcapi.delete_volume(context,
|
||||||
|
volume,
|
||||||
|
unmanage_only,
|
||||||
|
cascade)
|
||||||
LOG.info(_LI("Delete volume request issued successfully."),
|
LOG.info(_LI("Delete volume request issued successfully."),
|
||||||
resource=volume)
|
resource=volume)
|
||||||
|
|
||||||
|
@ -204,7 +204,7 @@ def locked_snapshot_operation(f):
|
|||||||
class VolumeManager(manager.SchedulerDependentManager):
|
class VolumeManager(manager.SchedulerDependentManager):
|
||||||
"""Manages attachable block storage devices."""
|
"""Manages attachable block storage devices."""
|
||||||
|
|
||||||
RPC_API_VERSION = '1.39'
|
RPC_API_VERSION = '1.40'
|
||||||
|
|
||||||
target = messaging.Target(version=RPC_API_VERSION)
|
target = messaging.Target(version=RPC_API_VERSION)
|
||||||
|
|
||||||
@ -636,8 +636,10 @@ class VolumeManager(manager.SchedulerDependentManager):
|
|||||||
return vol_ref.id
|
return vol_ref.id
|
||||||
|
|
||||||
@locked_volume_operation
|
@locked_volume_operation
|
||||||
def delete_volume(self, context, volume_id, unmanage_only=False,
|
def delete_volume(self, context, volume_id,
|
||||||
volume=None):
|
unmanage_only=False,
|
||||||
|
volume=None,
|
||||||
|
cascade=False):
|
||||||
"""Deletes and unexports volume.
|
"""Deletes and unexports volume.
|
||||||
|
|
||||||
1. Delete a volume(normal case)
|
1. Delete a volume(normal case)
|
||||||
@ -675,6 +677,13 @@ class VolumeManager(manager.SchedulerDependentManager):
|
|||||||
raise exception.InvalidVolume(
|
raise exception.InvalidVolume(
|
||||||
reason=_("volume is not local to this node"))
|
reason=_("volume is not local to this node"))
|
||||||
|
|
||||||
|
if unmanage_only and cascade:
|
||||||
|
# This could be done, but is ruled out for now just
|
||||||
|
# for simplicity.
|
||||||
|
raise exception.Invalid(
|
||||||
|
reason=_("Unmanage and cascade delete options "
|
||||||
|
"are mutually exclusive."))
|
||||||
|
|
||||||
# The status 'deleting' is not included, because it only applies to
|
# The status 'deleting' is not included, because it only applies to
|
||||||
# the source volume to be deleted after a migration. No quota
|
# the source volume to be deleted after a migration. No quota
|
||||||
# needs to be handled for it.
|
# needs to be handled for it.
|
||||||
@ -693,6 +702,25 @@ class VolumeManager(manager.SchedulerDependentManager):
|
|||||||
self.driver.remove_export(context, volume)
|
self.driver.remove_export(context, volume)
|
||||||
if unmanage_only:
|
if unmanage_only:
|
||||||
self.driver.unmanage(volume)
|
self.driver.unmanage(volume)
|
||||||
|
elif cascade:
|
||||||
|
LOG.debug('Performing cascade delete.')
|
||||||
|
snapshots = objects.SnapshotList.get_all_for_volume(context,
|
||||||
|
volume.id)
|
||||||
|
for s in snapshots:
|
||||||
|
if s.status != 'deleting':
|
||||||
|
self._clear_db(context, is_migrating_dest, volume,
|
||||||
|
'error_deleting')
|
||||||
|
|
||||||
|
msg = (_("Snapshot %(id)s was found in state "
|
||||||
|
"%(state)s rather than 'deleting' during "
|
||||||
|
"cascade delete.") % {'id': s.id,
|
||||||
|
'state': s.status})
|
||||||
|
raise exception.InvalidSnapshot(reason=msg)
|
||||||
|
|
||||||
|
self.delete_snapshot(context, s)
|
||||||
|
|
||||||
|
LOG.debug('Snapshots deleted, issuing volume delete')
|
||||||
|
self.driver.delete_volume(volume)
|
||||||
else:
|
else:
|
||||||
self.driver.delete_volume(volume)
|
self.driver.delete_volume(volume)
|
||||||
except exception.VolumeIsBusy:
|
except exception.VolumeIsBusy:
|
||||||
|
@ -92,9 +92,10 @@ class VolumeAPI(rpc.RPCAPI):
|
|||||||
1.38 - Scaling backup service, add get_backup_device() and
|
1.38 - Scaling backup service, add get_backup_device() and
|
||||||
secure_file_operations_enabled()
|
secure_file_operations_enabled()
|
||||||
1.39 - Update replication methods to reflect new backend rep strategy
|
1.39 - Update replication methods to reflect new backend rep strategy
|
||||||
|
1.40 - Add cascade option to delete_volume().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
RPC_API_VERSION = '1.39'
|
RPC_API_VERSION = '1.40'
|
||||||
TOPIC = CONF.volume_topic
|
TOPIC = CONF.volume_topic
|
||||||
BINARY = 'cinder-volume'
|
BINARY = 'cinder-volume'
|
||||||
|
|
||||||
@ -152,13 +153,22 @@ class VolumeAPI(rpc.RPCAPI):
|
|||||||
request_spec_p = jsonutils.to_primitive(request_spec)
|
request_spec_p = jsonutils.to_primitive(request_spec)
|
||||||
cctxt.cast(ctxt, 'create_volume', **msg_args)
|
cctxt.cast(ctxt, 'create_volume', **msg_args)
|
||||||
|
|
||||||
def delete_volume(self, ctxt, volume, unmanage_only=False):
|
def delete_volume(self, ctxt, volume, unmanage_only=False, cascade=False):
|
||||||
msg_args = {'volume_id': volume.id, 'unmanage_only': unmanage_only}
|
msg_args = {'volume_id': volume.id, 'unmanage_only': unmanage_only}
|
||||||
|
|
||||||
|
version = '1.15'
|
||||||
|
|
||||||
if self.client.can_send_version('1.33'):
|
if self.client.can_send_version('1.33'):
|
||||||
version = '1.33'
|
version = '1.33'
|
||||||
msg_args['volume'] = volume
|
msg_args['volume'] = volume
|
||||||
else:
|
|
||||||
version = '1.15'
|
if self.client.can_send_version('1.40'):
|
||||||
|
version = '1.40'
|
||||||
|
if cascade:
|
||||||
|
msg_args['cascade'] = cascade
|
||||||
|
elif cascade:
|
||||||
|
msg = _('Cascade option is not supported.')
|
||||||
|
raise exception.Invalid(reason=msg)
|
||||||
|
|
||||||
cctxt = self._get_cctxt(volume.host, version)
|
cctxt = self._get_cctxt(volume.host, version)
|
||||||
cctxt.cast(ctxt, 'delete_volume', **msg_args)
|
cctxt.cast(ctxt, 'delete_volume', **msg_args)
|
||||||
|
Loading…
Reference in New Issue
Block a user