Add amphora delete API

This patch adds an amphora delete API. It can be used to delete
extra "spare" amphora after the feature has been disabled.

A followup patch will be required for the amphorav2 path as the
amphorav2 failover patch, which is required for the amphora delete
flow, has not yet merged.

Story: 2008014
Task: 40666

Change-Id: I32b6561c78c153a4b7e73b1a4b83e045fbe97fb6
This commit is contained in:
Michael Johnson 2020-04-07 18:31:00 -07:00
parent 4a4a2344de
commit 59dcdd9a86
14 changed files with 285 additions and 3 deletions

View File

@ -302,3 +302,46 @@ Response
-------- --------
There is no body content for the response of a successful PUT request. There is no body content for the response of a successful PUT request.
Remove an Amphora
=================
.. rest_method:: DELETE /v2/octavia/amphorae/{amphora_id}
Removes an amphora and its associated configuration.
The API immediately purges any and all configuration data, depending on the
configuration settings. You cannot recover it.
**New in version 2.20**
.. rest_status_code:: success ../http-status.yaml
- 204
.. rest_status_code:: error ../http-status.yaml
- 400
- 401
- 403
- 404
- 409
- 500
Request
-------
.. rest_parameters:: ../parameters.yaml
- amphora_id: path-amphora-id
Curl Example
------------
.. literalinclude:: examples/amphora-delete-curl
:language: bash
Response
--------
There is no body content for the response of a successful DELETE request.

View File

@ -0,0 +1 @@
curl -X DELETE -H "X-Auth-Token: <token>" http://198.51.100.10:9876/v2/octavia/amphorae/1a032adb-d6ac-4dbb-a04a-c1126bc547c7

View File

@ -125,6 +125,9 @@ class RootController(object):
self._add_a_version(versions, 'v2.19', 'v2', 'SUPPORTED', self._add_a_version(versions, 'v2.19', 'v2', 'SUPPORTED',
'2020-05-12T00:00:00Z', host_url) '2020-05-12T00:00:00Z', host_url)
# ALPN protocols # ALPN protocols
self._add_a_version(versions, 'v2.20', 'v2', 'CURRENT', self._add_a_version(versions, 'v2.20', 'v2', 'SUPPORTED',
'2020-08-02T00:00:00Z', host_url) '2020-08-02T00:00:00Z', host_url)
# Amphora delete
self._add_a_version(versions, 'v2.21', 'v2', 'CURRENT',
'2020-09-03T00:00:00Z', host_url)
return {'versions': versions} return {'versions': versions}

View File

@ -19,6 +19,7 @@ import oslo_messaging as messaging
from oslo_utils import excutils from oslo_utils import excutils
from pecan import expose as pecan_expose from pecan import expose as pecan_expose
from pecan import request as pecan_request from pecan import request as pecan_request
from sqlalchemy.orm import exc as sa_exception
from wsme import types as wtypes from wsme import types as wtypes
from wsmeext import pecan as wsme_pecan from wsmeext import pecan as wsme_pecan
@ -27,6 +28,7 @@ from octavia.api.v2.types import amphora as amp_types
from octavia.common import constants from octavia.common import constants
from octavia.common import exceptions from octavia.common import exceptions
from octavia.common import rpc from octavia.common import rpc
from octavia.db import api as db_api
CONF = cfg.CONF CONF = cfg.CONF
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -37,6 +39,11 @@ class AmphoraController(base.BaseController):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
topic = cfg.CONF.oslo_messaging.topic
self.target = messaging.Target(
namespace=constants.RPC_NAMESPACE_CONTROLLER_AGENT,
topic=topic, version="1.0", fanout=False)
self.client = rpc.get_client(self.target)
@wsme_pecan.wsexpose(amp_types.AmphoraRootResponse, wtypes.text, @wsme_pecan.wsexpose(amp_types.AmphoraRootResponse, wtypes.text,
[wtypes.text], ignore_extra_args=True) [wtypes.text], ignore_extra_args=True)
@ -57,7 +64,7 @@ class AmphoraController(base.BaseController):
@wsme_pecan.wsexpose(amp_types.AmphoraeRootResponse, [wtypes.text], @wsme_pecan.wsexpose(amp_types.AmphoraeRootResponse, [wtypes.text],
ignore_extra_args=True) ignore_extra_args=True)
def get_all(self, fields=None): def get_all(self, fields=None):
"""Gets all health monitors.""" """Gets all amphorae."""
pcontext = pecan_request.context pcontext = pecan_request.context
context = pcontext.get('octavia_context') context = pcontext.get('octavia_context')
@ -74,6 +81,25 @@ class AmphoraController(base.BaseController):
return amp_types.AmphoraeRootResponse( return amp_types.AmphoraeRootResponse(
amphorae=result, amphorae_links=links) amphorae=result, amphorae_links=links)
@wsme_pecan.wsexpose(None, wtypes.text, status_code=204)
def delete(self, id):
"""Deletes an amphora."""
context = pecan_request.context.get('octavia_context')
self._auth_validate_action(context, context.project_id,
constants.RBAC_DELETE)
with db_api.get_lock_session() as lock_session:
try:
self.repositories.amphora.test_and_set_status_for_delete(
lock_session, id)
except sa_exception.NoResultFound as e:
raise exceptions.NotFound(resource='Amphora', id=id) from e
LOG.info("Sending delete amphora %s to the queue.", id)
payload = {constants.AMPHORA_ID: id}
self.client.cast({}, 'delete_amphora', **payload)
@pecan_expose() @pecan_expose()
def _lookup(self, amphora_id, *remainder): def _lookup(self, amphora_id, *remainder):
"""Overridden pecan _lookup method for custom routing. """Overridden pecan _lookup method for custom routing.

View File

@ -154,3 +154,7 @@ class Endpoints(object):
LOG.info('Updating amphora \'%s\' agent configuration...', LOG.info('Updating amphora \'%s\' agent configuration...',
amphora_id) amphora_id)
self.worker.update_amphora_agent_config(amphora_id) self.worker.update_amphora_agent_config(amphora_id)
def delete_amphora(self, context, amphora_id):
LOG.info('Deleting amphora \'%s\'...', amphora_id)
self.worker.delete_amphora(amphora_id)

View File

@ -112,6 +112,26 @@ class ControllerWorker(base_taskflow.BaseTaskFlowEngine):
except Exception as e: except Exception as e:
LOG.error('Failed to create an amphora due to: {}'.format(str(e))) LOG.error('Failed to create an amphora due to: {}'.format(str(e)))
def delete_amphora(self, amphora_id):
"""Deletes an existing Amphora.
:param amphora_id: ID of the amphora to delete
:returns: None
:raises AmphoraNotFound: The referenced Amphora was not found
"""
try:
amphora = self._amphora_repo.get(db_apis.get_session(),
id=amphora_id)
delete_amp_tf = self.taskflow_load(
self._amphora_flows.get_delete_amphora_flow(amphora))
with tf_logging.DynamicLoggingListener(delete_amp_tf, log=LOG):
delete_amp_tf.run()
except Exception as e:
LOG.error('Failed to delete a amphora {0} due to: {1}'.format(
amphora_id, str(e)))
return
LOG.info('Finished deleting amphora %s.', amphora_id)
@tenacity.retry( @tenacity.retry(
retry=tenacity.retry_if_exception_type(db_exceptions.NoResultFound), retry=tenacity.retry_if_exception_type(db_exceptions.NoResultFound),
wait=tenacity.wait_incrementing( wait=tenacity.wait_incrementing(

View File

@ -1492,6 +1492,27 @@ class AmphoraRepository(BaseRepository):
return lb return lb
def test_and_set_status_for_delete(self, lock_session, id):
"""Tests and sets an amphora status.
Puts a lock on the amphora table to check the status of the
amphora. The status must be either AMPHORA_READY or ERROR to
successfuly update the amphora status.
:param lock_session: A Sql Alchemy database session.
:param id: id of Load Balancer
:raises ImmutableObject: The amphora is not in a state that can be
deleted.
:raises NoResultFound: The amphora was not found or already deleted.
:returns: None
"""
amp = lock_session.query(self.model_class).with_for_update().filter_by(
id=id).filter(self.model_class.status != consts.DELETED).one()
if amp.status not in [consts.AMPHORA_READY, consts.ERROR]:
raise exceptions.ImmutableObject(resource=consts.AMPHORA, id=id)
amp.status = consts.PENDING_DELETE
lock_session.flush()
class AmphoraBuildReqRepository(BaseRepository): class AmphoraBuildReqRepository(BaseRepository):
model_class = models.AmphoraBuildRequest model_class = models.AmphoraBuildRequest

View File

@ -30,6 +30,13 @@ rules = [
"Show Amphora details", "Show Amphora details",
[{'method': 'GET', 'path': '/v2/octavia/amphorae/{amphora_id}'}] [{'method': 'GET', 'path': '/v2/octavia/amphorae/{amphora_id}'}]
), ),
policy.DocumentedRuleDefault(
'{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_AMPHORA,
action=constants.RBAC_DELETE),
constants.RULE_API_ADMIN,
"Delete an Amphora",
[{'method': 'DELETE', 'path': '/v2/octavia/amphorae/{amphora_id}'}]
),
policy.DocumentedRuleDefault( policy.DocumentedRuleDefault(
'{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_AMPHORA, '{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_AMPHORA,
action=constants.RBAC_PUT_CONFIG), action=constants.RBAC_PUT_CONFIG),

View File

@ -45,7 +45,7 @@ class TestRootController(base_db_test.OctaviaDBTestBase):
def test_api_versions(self): def test_api_versions(self):
versions = self._get_versions_with_config() versions = self._get_versions_with_config()
version_ids = tuple(v.get('id') for v in versions) version_ids = tuple(v.get('id') for v in versions)
self.assertEqual(21, len(version_ids)) self.assertEqual(22, len(version_ids))
self.assertIn('v2.0', version_ids) self.assertIn('v2.0', version_ids)
self.assertIn('v2.1', version_ids) self.assertIn('v2.1', version_ids)
self.assertIn('v2.2', version_ids) self.assertIn('v2.2', version_ids)
@ -67,6 +67,7 @@ class TestRootController(base_db_test.OctaviaDBTestBase):
self.assertIn('v2.18', version_ids) self.assertIn('v2.18', version_ids)
self.assertIn('v2.19', version_ids) self.assertIn('v2.19', version_ids)
self.assertIn('v2.20', version_ids) self.assertIn('v2.20', version_ids)
self.assertIn('v2.21', version_ids)
# Each version should have a 'self' 'href' to the API version URL # Each version should have a 'self' 'href' to the API version URL
# [{u'rel': u'self', u'href': u'http://localhost/v2'}] # [{u'rel': u'self', u'href': u'http://localhost/v2'}]

View File

@ -127,6 +127,102 @@ class TestAmphora(base.BaseAPITest):
amphora_id=self.amp_id)).json.get(self.root_tag) amphora_id=self.amp_id)).json.get(self.root_tag)
self._assert_amp_equal(self.amp_args, response) self._assert_amp_equal(self.amp_args, response)
@mock.patch('oslo_messaging.RPCClient.cast')
def test_delete(self, mock_cast):
self.amp_args = {
'status': constants.AMPHORA_READY,
}
amp = self.amphora_repo.create(self.session, **self.amp_args)
self.delete(self.AMPHORA_PATH.format(
amphora_id=amp.id), status=204)
response = self.get(self.AMPHORA_PATH.format(
amphora_id=amp.id)).json.get(self.root_tag)
self.assertEqual(constants.PENDING_DELETE, response[constants.STATUS])
payload = {constants.AMPHORA_ID: amp.id}
mock_cast.assert_called_with({}, 'delete_amphora', **payload)
@mock.patch('oslo_messaging.RPCClient.cast')
def test_delete_not_found(self, mock_cast):
self.delete(self.AMPHORA_PATH.format(amphora_id='bogus-id'),
status=404)
mock_cast.assert_not_called()
@mock.patch('oslo_messaging.RPCClient.cast')
def test_delete_immutable(self, mock_cast):
self.amp_args = {
'status': constants.AMPHORA_ALLOCATED,
}
amp = self.amphora_repo.create(self.session, **self.amp_args)
self.delete(self.AMPHORA_PATH.format(
amphora_id=amp.id), status=409)
mock_cast.assert_not_called()
@mock.patch('oslo_messaging.RPCClient.cast')
def test_delete_authorized(self, mock_cast):
self.amp_args = {
'status': constants.AMPHORA_READY,
}
amp = self.amphora_repo.create(self.session, **self.amp_args)
self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF))
auth_strategy = self.conf.conf.api_settings.get('auth_strategy')
self.conf.config(group='api_settings', auth_strategy=constants.TESTING)
with mock.patch.object(octavia.common.context.Context, 'project_id',
self.project_id):
override_credentials = {
'service_user_id': None,
'user_domain_id': None,
'is_admin_project': True,
'service_project_domain_id': None,
'service_project_id': None,
'roles': ['load-balancer_member'],
'user_id': None,
'is_admin': True,
'service_user_domain_id': None,
'project_domain_id': None,
'service_roles': [],
'project_id': self.project_id}
with mock.patch(
"oslo_context.context.RequestContext.to_policy_values",
return_value=override_credentials):
self.delete(self.AMPHORA_PATH.format(amphora_id=amp.id),
status=204)
# Reset api auth setting
self.conf.config(group='api_settings', auth_strategy=auth_strategy)
response = self.get(self.AMPHORA_PATH.format(
amphora_id=amp.id)).json.get(self.root_tag)
self.assertEqual(constants.PENDING_DELETE, response[constants.STATUS])
payload = {constants.AMPHORA_ID: amp.id}
mock_cast.assert_called_with({}, 'delete_amphora', **payload)
@mock.patch('oslo_messaging.RPCClient.cast')
def test_delete_not_authorized(self, mock_cast):
self.amp_args = {
'status': constants.AMPHORA_READY,
}
amp = self.amphora_repo.create(self.session, **self.amp_args)
self.conf = self.useFixture(oslo_fixture.Config(cfg.CONF))
auth_strategy = self.conf.conf.api_settings.get('auth_strategy')
self.conf.config(group='api_settings', auth_strategy=constants.TESTING)
with mock.patch.object(octavia.common.context.Context, 'project_id',
self.project_id):
self.delete(self.AMPHORA_PATH.format(amphora_id=amp.id),
status=403)
# Reset api auth setting
self.conf.config(group='api_settings', auth_strategy=auth_strategy)
mock_cast.assert_not_called()
@mock.patch('oslo_messaging.RPCClient.cast') @mock.patch('oslo_messaging.RPCClient.cast')
def test_failover(self, mock_cast): def test_failover(self, mock_cast):
self.put(self.AMPHORA_FAILOVER_PATH.format( self.put(self.AMPHORA_FAILOVER_PATH.format(

View File

@ -4022,6 +4022,29 @@ class AmphoraRepositoryTest(BaseRepositoryTest):
self.FAKE_UUID_1) self.FAKE_UUID_1)
self.assertEqual(lb_ref, lb) self.assertEqual(lb_ref, lb)
def test_and_set_status_for_delete(self):
# Normal path
amphora = self.create_amphora(self.FAKE_UUID_1,
status=constants.AMPHORA_READY)
self.amphora_repo.test_and_set_status_for_delete(self.session,
amphora.id)
new_amphora = self.amphora_repo.get(self.session, id=amphora.id)
self.assertEqual(constants.PENDING_DELETE, new_amphora.status)
# Test deleted path
amphora = self.create_amphora(self.FAKE_UUID_2,
status=constants.DELETED)
self.assertRaises(sa_exception.NoResultFound,
self.amphora_repo.test_and_set_status_for_delete,
self.session, amphora.id)
# Test in use path
amphora = self.create_amphora(self.FAKE_UUID_3,
status=constants.AMPHORA_ALLOCATED)
self.assertRaises(exceptions.ImmutableObject,
self.amphora_repo.test_and_set_status_for_delete,
self.session, amphora.id)
class AmphoraHealthRepositoryTest(BaseRepositoryTest): class AmphoraHealthRepositoryTest(BaseRepositoryTest):
def setUp(self): def setUp(self):

View File

@ -182,3 +182,8 @@ class TestEndpoints(base.TestCase):
self.ep.update_amphora_agent_config(self.context, self.resource_id) self.ep.update_amphora_agent_config(self.context, self.resource_id)
self.ep.worker.update_amphora_agent_config.assert_called_once_with( self.ep.worker.update_amphora_agent_config.assert_called_once_with(
self.resource_id) self.resource_id)
def test_delete_amphora(self):
self.ep.delete_amphora(self.context, self.resource_id)
self.ep.worker.delete_amphora.assert_called_once_with(
self.resource_id)

View File

@ -157,6 +157,34 @@ class TestControllerWorker(base.TestCase):
self.assertEqual(AMP_ID, amp) self.assertEqual(AMP_ID, amp)
@mock.patch('octavia.controller.worker.v1.flows.'
'amphora_flows.AmphoraFlows.get_delete_amphora_flow',
return_value='TEST')
def test_delete_amphora(self,
mock_get_delete_amp_flow,
mock_api_get_session,
mock_dyn_log_listener,
mock_taskflow_load,
mock_pool_repo_get,
mock_member_repo_get,
mock_l7rule_repo_get,
mock_l7policy_repo_get,
mock_listener_repo_get,
mock_lb_repo_get,
mock_health_mon_repo_get,
mock_amp_repo_get):
_flow_mock.reset_mock()
cw = controller_worker.ControllerWorker()
cw.delete_amphora(_amphora_mock.id)
(base_taskflow.BaseTaskFlowEngine.taskflow_load.
assert_called_once_with('TEST'))
mock_get_delete_amp_flow.assert_called_once_with(_amphora_mock)
_flow_mock.run.assert_called_once_with()
@mock.patch('octavia.db.repositories.AvailabilityZoneRepository.' @mock.patch('octavia.db.repositories.AvailabilityZoneRepository.'
'get_availability_zone_metadata_dict') 'get_availability_zone_metadata_dict')
@mock.patch('octavia.controller.worker.v1.flows.' @mock.patch('octavia.controller.worker.v1.flows.'

View File

@ -0,0 +1,4 @@
---
features:
- |
Added the ability to delete amphora that are not in use.