diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index 74dce39a84..456a1108c3 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -143,6 +143,13 @@ amphora-role: in: body required: true type: string +amphora-stats: + description: | + A list of amphora statistics objects, one per listener. + in: body + min_version: 2.3 + required: true + type: array amphora-status: description: | The status of the amphora. One of: ``BOOTING``, ``ALLOCATED``, ``READY``, diff --git a/api-ref/source/v2/amphora.inc b/api-ref/source/v2/amphora.inc index d6761cdb63..e150e42203 100644 --- a/api-ref/source/v2/amphora.inc +++ b/api-ref/source/v2/amphora.inc @@ -79,7 +79,7 @@ Response Example :language: javascript Show Amphora details -=========================== +==================== .. rest_method:: GET /v2/octavia/amphorae/{amphora_id} @@ -153,6 +153,68 @@ Response Example .. literalinclude:: examples/amphora-show-response.json :language: javascript +Show Amphora Statistics +======================= + +.. rest_method:: GET /v2/octavia/amphorae/{amphora_id}/stats + +Show the statistics for an amphora. + +If you are not an administrative user, the service returns the HTTP +``Forbidden (403)`` response code. + +Use the ``fields`` query parameter to control which fields are +returned in the response body. + +**New in version 2.3** + +.. rest_status_code:: success ../http-status.yaml + + - 200 + +.. rest_status_code:: error ../http-status.yaml + + - 400 + - 401 + - 403 + - 404 + - 500 + +Request +------- + +.. rest_parameters:: ../parameters.yaml + + - amphora_id: path-amphora-id + - fields: fields + +Curl Example +------------ + +.. literalinclude:: examples/amphora-show-stats-curl + :language: bash + +Response Parameters +------------------- + +.. rest_parameters:: ../parameters.yaml + + - active_connections: active_connections + - amphora_stats: amphora-stats + - bytes_in: bytes_in + - bytes_out: bytes_out + - id: amphora-id + - listener_id: listener-id + - loadbalancer_id: loadbalancer-id + - request_errors: request_errors + - total_connections: total_connections + +Response Example +---------------- + +.. literalinclude:: examples/amphora-show-stats-response.json + :language: javascript + Failover Amphora ================ diff --git a/api-ref/source/v2/examples/amphora-show-stats-curl b/api-ref/source/v2/examples/amphora-show-stats-curl new file mode 100644 index 0000000000..3f9f5d1702 --- /dev/null +++ b/api-ref/source/v2/examples/amphora-show-stats-curl @@ -0,0 +1 @@ +curl -X GET -H "X-Auth-Token: " http://198.51.100.10:9876/v2/octavia/amphorae/63d8349e-c4d7-4156-bc94-29260607b04f/stats diff --git a/api-ref/source/v2/examples/amphora-show-stats-response.json b/api-ref/source/v2/examples/amphora-show-stats-response.json new file mode 100644 index 0000000000..33317f11a6 --- /dev/null +++ b/api-ref/source/v2/examples/amphora-show-stats-response.json @@ -0,0 +1,24 @@ +{ + "amphora_stats": [ + { + "active_connections": 48629, + "bytes_in": 65671420, + "bytes_out": 774771186, + "id": "63d8349e-c4d7-4156-bc94-29260607b04f", + "listener_id": "bbe44114-cda2-4fe0-b192-d9e24ce661db", + "loadbalancer_id": "65b5a7c3-1437-4909-84cf-cec9f7e371ea", + "request_errors": 0, + "total_connections": 26189172 + }, + { + "active_connections": 0, + "bytes_in": 5, + "bytes_out": 100, + "id": "63d8349e-c4d7-4156-bc94-29260607b04f", + "listener_id": "af45a658-4eeb-4ce9-8b7e-16b0e5676f87", + "loadbalancer_id": "65b5a7c3-1437-4909-84cf-cec9f7e371ea", + "request_errors": 0, + "total_connections": 1 + } + ] +} diff --git a/octavia/api/root_controller.py b/octavia/api/root_controller.py index f2f796e55d..58a9ff2344 100644 --- a/octavia/api/root_controller.py +++ b/octavia/api/root_controller.py @@ -77,6 +77,8 @@ class RootController(rest.RestController): '2018-04-20T00:00:00Z', host_url) self._add_a_version(versions, 'v2.2', 'v2', 'SUPPORTED', '2018-07-31T00:00:00Z', host_url) - self._add_a_version(versions, 'v2.3', 'v2', 'CURRENT', + self._add_a_version(versions, 'v2.3', 'v2', 'SUPPORTED', '2018-12-18T00:00:00Z', host_url) + self._add_a_version(versions, 'v2.4', 'v2', 'CURRENT', + '2018-12-19T00:00:00Z', host_url) return {'versions': versions} diff --git a/octavia/api/v2/controllers/amphora.py b/octavia/api/v2/controllers/amphora.py index a22728bd60..ff2846a4e7 100644 --- a/octavia/api/v2/controllers/amphora.py +++ b/octavia/api/v2/controllers/amphora.py @@ -24,6 +24,7 @@ from wsmeext import pecan as wsme_pecan from octavia.api.v2.controllers import base from octavia.api.v2.types import amphora as amp_types from octavia.common import constants +from octavia.common import exceptions CONF = cfg.CONF @@ -84,6 +85,8 @@ class AmphoraController(base.BaseController): remainder = remainder[1:] if controller == 'failover': return FailoverController(amp_id=amphora_id), remainder + if controller == 'stats': + return AmphoraStatsController(amp_id=amphora_id), remainder return None @@ -131,3 +134,30 @@ class FailoverController(base.BaseController): self.repositories.load_balancer.update( context.session, db_amp.load_balancer.id, provisioning_status=constants.ERROR) + + +class AmphoraStatsController(base.BaseController): + RBAC_TYPE = constants.RBAC_AMPHORA + + def __init__(self, amp_id): + super(AmphoraStatsController, self).__init__() + self.amp_id = amp_id + + @wsme_pecan.wsexpose(amp_types.StatisticsRootResponse, wtypes.text, + status_code=200) + def get(self): + context = pecan.request.context.get('octavia_context') + + self._auth_validate_action(context, context.project_id, + constants.RBAC_GET_STATS) + + stats = self.repositories.get_amphora_stats(context.session, + self.amp_id) + if stats == []: + raise exceptions.NotFound(resource='Amphora stats for', + id=self.amp_id) + + wsme_stats = [] + for stat in stats: + wsme_stats.append(amp_types.AmphoraStatisticsResponse(**stat)) + return amp_types.StatisticsRootResponse(amphora_stats=wsme_stats) diff --git a/octavia/api/v2/types/amphora.py b/octavia/api/v2/types/amphora.py index ec319b65bf..d414e1046d 100644 --- a/octavia/api/v2/types/amphora.py +++ b/octavia/api/v2/types/amphora.py @@ -60,3 +60,19 @@ class AmphoraRootResponse(types.BaseType): class AmphoraeRootResponse(types.BaseType): amphorae = wtypes.wsattr([AmphoraResponse]) amphorae_links = wtypes.wsattr([types.PageType]) + + +class AmphoraStatisticsResponse(BaseAmphoraType): + """Defines which attributes are to show on stats response.""" + active_connections = wtypes.wsattr(wtypes.IntegerType()) + bytes_in = wtypes.wsattr(wtypes.IntegerType()) + bytes_out = wtypes.wsattr(wtypes.IntegerType()) + id = wtypes.wsattr(wtypes.UuidType()) + listener_id = wtypes.wsattr(wtypes.UuidType()) + loadbalancer_id = wtypes.wsattr(wtypes.UuidType()) + request_errors = wtypes.wsattr(wtypes.IntegerType()) + total_connections = wtypes.wsattr(wtypes.IntegerType()) + + +class StatisticsRootResponse(types.BaseType): + amphora_stats = wtypes.wsattr([AmphoraStatisticsResponse]) diff --git a/octavia/common/constants.py b/octavia/common/constants.py index f2ed4b8ddb..3101d6751b 100644 --- a/octavia/common/constants.py +++ b/octavia/common/constants.py @@ -258,6 +258,11 @@ REQ_CONN_TIMEOUT = 'req_conn_timeout' REQ_READ_TIMEOUT = 'req_read_timeout' CONN_MAX_RETRIES = 'conn_max_retries' CONN_RETRY_INTERVAL = 'conn_retry_interval' +ACTIVE_CONNECTIONS = 'active_connections' +BYTES_IN = 'bytes_in' +BYTES_OUT = 'bytes_out' +REQUEST_ERRORS = 'request_errors' +TOTAL_CONNECTIONS = 'total_connections' CERT_ROTATE_AMPHORA_FLOW = 'octavia-cert-rotate-amphora-flow' CREATE_AMPHORA_FLOW = 'octavia-create-amphora-flow' diff --git a/octavia/db/repositories.py b/octavia/db/repositories.py index a091d4f843..e916aa0c70 100644 --- a/octavia/db/repositories.py +++ b/octavia/db/repositories.py @@ -677,6 +677,34 @@ class Repositories(object): session.expire_all() return self.load_balancer.get(session, id=lb_dm.id) + def get_amphora_stats(self, session, amp_id): + """Gets the statistics for all listeners on an amphora. + + :param session: A Sql Alchemy database session. + :param amp_id: The amphora ID to query. + :returns: An amphora stats dictionary + """ + with session.begin(subtransactions=True): + columns = (models.ListenerStatistics.__table__.columns + + [models.Amphora.load_balancer_id]) + amp_records = ( + session.query(*columns) + .filter(models.ListenerStatistics.amphora_id == amp_id) + .filter(models.ListenerStatistics.amphora_id == + models.Amphora.id).all()) + amp_stats = [] + for amp in amp_records: + amp_stat = {consts.LOADBALANCER_ID: amp.load_balancer_id, + consts.LISTENER_ID: amp.listener_id, + 'id': amp.amphora_id, + consts.ACTIVE_CONNECTIONS: amp.active_connections, + consts.BYTES_IN: amp.bytes_in, + consts.BYTES_OUT: amp.bytes_out, + consts.REQUEST_ERRORS: amp.request_errors, + consts.TOTAL_CONNECTIONS: amp.total_connections} + amp_stats.append(amp_stat) + return amp_stats + class LoadBalancerRepository(BaseRepository): model_class = models.LoadBalancer diff --git a/octavia/policies/amphora.py b/octavia/policies/amphora.py index 8b406563b8..bcc85df23d 100644 --- a/octavia/policies/amphora.py +++ b/octavia/policies/amphora.py @@ -38,6 +38,13 @@ rules = [ [{'method': 'PUT', 'path': '/v2/octavia/amphorae/{amphora_id}/failover'}] ), + policy.DocumentedRuleDefault( + '{rbac_obj}{action}'.format(rbac_obj=constants.RBAC_AMPHORA, + action=constants.RBAC_GET_STATS), + constants.RULE_API_ADMIN, + "Show Amphora statistics", + [{'method': 'GET', 'path': '/v2/octavia/amphorae/{amphora_id}/stats'}] + ), ] diff --git a/octavia/tests/functional/api/test_root_controller.py b/octavia/tests/functional/api/test_root_controller.py index 897f8e5de8..0578547600 100644 --- a/octavia/tests/functional/api/test_root_controller.py +++ b/octavia/tests/functional/api/test_root_controller.py @@ -46,12 +46,13 @@ class TestRootController(base_db_test.OctaviaDBTestBase): versions = self._get_versions_with_config( api_v1_enabled=True, api_v2_enabled=True) version_ids = tuple(v.get('id') for v in versions) - self.assertEqual(5, len(version_ids)) + self.assertEqual(6, len(version_ids)) self.assertIn('v1', version_ids) self.assertIn('v2.0', version_ids) self.assertIn('v2.1', version_ids) self.assertIn('v2.2', version_ids) self.assertIn('v2.3', version_ids) + self.assertIn('v2.4', version_ids) # Each version should have a 'self' 'href' to the API version URL # [{u'rel': u'self', u'href': u'http://localhost/v2'}] @@ -71,11 +72,12 @@ class TestRootController(base_db_test.OctaviaDBTestBase): def test_api_v1_disabled(self): versions = self._get_versions_with_config( api_v1_enabled=False, api_v2_enabled=True) - self.assertEqual(4, len(versions)) + self.assertEqual(5, len(versions)) self.assertEqual('v2.0', versions[0].get('id')) self.assertEqual('v2.1', versions[1].get('id')) self.assertEqual('v2.2', versions[2].get('id')) self.assertEqual('v2.3', versions[3].get('id')) + self.assertEqual('v2.4', versions[4].get('id')) def test_api_v2_disabled(self): versions = self._get_versions_with_config( diff --git a/octavia/tests/functional/api/v2/base.py b/octavia/tests/functional/api/v2/base.py index bd663674ae..46ec0f025e 100644 --- a/octavia/tests/functional/api/v2/base.py +++ b/octavia/tests/functional/api/v2/base.py @@ -68,6 +68,7 @@ class BaseAPITest(base_db_test.OctaviaDBTestBase): AMPHORAE_PATH = '/octavia/amphorae' AMPHORA_PATH = AMPHORAE_PATH + '/{amphora_id}' AMPHORA_FAILOVER_PATH = AMPHORA_PATH + '/failover' + AMPHORA_STATS_PATH = AMPHORA_PATH + '/stats' PROVIDERS_PATH = '/lbaas/providers' diff --git a/octavia/tests/functional/api/v2/test_amphora.py b/octavia/tests/functional/api/v2/test_amphora.py index cc477b4271..841a84a1b5 100644 --- a/octavia/tests/functional/api/v2/test_amphora.py +++ b/octavia/tests/functional/api/v2/test_amphora.py @@ -29,6 +29,7 @@ class TestAmphora(base.BaseAPITest): root_tag = 'amphora' root_tag_list = 'amphorae' root_tag_links = 'amphorae_links' + root_tag_stats = 'amphora_stats' def setUp(self): super(TestAmphora, self).setUp() @@ -61,6 +62,34 @@ class TestAmphora(base.BaseAPITest): self.amp = self.amphora_repo.create(self.session, **self.amp_args) self.amp_id = self.amp.id self.amp_args['id'] = self.amp_id + self.listener1_id = uuidutils.generate_uuid() + self.create_listener_stats_dynamic(self.listener1_id, self.amp_id, + bytes_in=1, bytes_out=2, + active_connections=3, + total_connections=4, + request_errors=5) + self.listener2_id = uuidutils.generate_uuid() + self.create_listener_stats_dynamic(self.listener2_id, self.amp_id, + bytes_in=6, bytes_out=7, + active_connections=8, + total_connections=9, + request_errors=10) + self.listener1_amp_stats = {'active_connections': 3, + 'bytes_in': 1, 'bytes_out': 2, + 'id': self.amp_id, + 'listener_id': self.listener1_id, + 'loadbalancer_id': self.lb_id, + 'request_errors': 5, + 'total_connections': 4} + self.listener2_amp_stats = {'active_connections': 8, + 'bytes_in': 6, 'bytes_out': 7, + 'id': self.amp_id, + 'listener_id': self.listener2_id, + 'loadbalancer_id': self.lb_id, + 'request_errors': 10, + 'total_connections': 9} + self.ref_amp_stats = [self.listener1_amp_stats, + self.listener2_amp_stats] def _create_additional_amp(self): amp_args = { @@ -398,3 +427,77 @@ class TestAmphora(base.BaseAPITest): response = self.get(self.AMPHORAE_PATH).json.get(self.root_tag_list) self.assertIsInstance(response, list) self.assertEqual(0, len(response)) + + def test_get_stats_authorized(self): + 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): + response = self.get(self.AMPHORA_STATS_PATH.format( + amphora_id=self.amp_id)).json.get(self.root_tag_stats) + # Reset api auth setting + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + self.assertEqual(self.ref_amp_stats, response) + + def test_get_stats_not_authorized(self): + 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', + uuidutils.generate_uuid()): + response = self.get(self.AMPHORA_STATS_PATH.format( + amphora_id=self.amp_id), status=403) + # Reset api auth setting + self.conf.config(group='api_settings', auth_strategy=auth_strategy) + self.assertEqual(self.NOT_AUTHORIZED_BODY, response.json) + + def test_get_stats_bad_amp_id(self): + self.get(self.AMPHORA_STATS_PATH.format( + amphora_id='bogus_id'), status=404) + + def test_get_stats_no_listeners(self): + self.lb2 = self.create_load_balancer( + uuidutils.generate_uuid()).get('loadbalancer') + self.lb2_id = self.lb2.get('id') + self.set_lb_status(self.lb2_id) + self.amp2_args = { + 'load_balancer_id': self.lb2_id, + 'compute_id': uuidutils.generate_uuid(), + 'lb_network_ip': '192.168.1.20', + 'vrrp_ip': '192.168.1.5', + 'ha_ip': '192.168.1.100', + 'vrrp_port_id': uuidutils.generate_uuid(), + 'ha_port_id': uuidutils.generate_uuid(), + 'cert_expiration': datetime.datetime.now(), + 'cert_busy': False, + 'role': constants.ROLE_STANDALONE, + 'status': constants.AMPHORA_ALLOCATED, + 'vrrp_interface': 'eth1', + 'vrrp_id': 1, + 'vrrp_priority': 100, + 'cached_zone': None, + 'created_at': datetime.datetime.now(), + 'updated_at': datetime.datetime.now(), + 'image_id': uuidutils.generate_uuid(), + } + self.amp2 = self.amphora_repo.create(self.session, **self.amp2_args) + self.amp2_id = self.amp2.id + self.get(self.AMPHORA_STATS_PATH.format( + amphora_id=self.amp2_id), status=404) diff --git a/octavia/tests/functional/db/test_repositories.py b/octavia/tests/functional/db/test_repositories.py index ad24b6b6ee..b13aebabb7 100644 --- a/octavia/tests/functional/db/test_repositories.py +++ b/octavia/tests/functional/db/test_repositories.py @@ -82,6 +82,8 @@ class AllRepositoriesTest(base.OctaviaDBTestBase): FAKE_UUID_1 = uuidutils.generate_uuid() FAKE_UUID_2 = uuidutils.generate_uuid() + FAKE_UUID_3 = uuidutils.generate_uuid() + FAKE_IP = '192.0.2.44' def setUp(self): super(AllRepositoriesTest, self).setUp() @@ -96,6 +98,11 @@ class AllRepositoriesTest(base.OctaviaDBTestBase): enabled=True, provisioning_status=constants.ACTIVE, operating_status=constants.ONLINE, load_balancer_id=self.load_balancer.id) + self.amphora = self.repos.amphora.create( + self.session, id=uuidutils.generate_uuid(), + load_balancer_id=self.load_balancer.id, + compute_id=self.FAKE_UUID_3, status=constants.ACTIVE, + vrrp_ip=self.FAKE_IP, lb_network_ip=self.FAKE_IP) def test_all_repos_has_correct_repos(self): repo_attr_names = ('load_balancer', 'vip', 'health_monitor', @@ -1837,6 +1844,40 @@ class AllRepositoriesTest(base.OctaviaDBTestBase): self.session, project_id=project_id).in_use_member) conf.config(group='api_settings', auth_strategy=constants.TESTING) + def test_get_amphora_stats(self): + listener2_id = uuidutils.generate_uuid() + self.repos.listener_stats.create( + self.session, listener_id=self.listener.id, + amphora_id=self.amphora.id, bytes_in=1, bytes_out=2, + active_connections=3, total_connections=4, request_errors=5) + self.repos.listener_stats.create( + self.session, listener_id=listener2_id, + amphora_id=self.amphora.id, bytes_in=6, bytes_out=7, + active_connections=8, total_connections=9, request_errors=10) + amp_stats = self.repos.get_amphora_stats(self.session, self.amphora.id) + self.assertEqual(2, len(amp_stats)) + for stats in amp_stats: + if stats['listener_id'] == self.listener.id: + self.assertEqual(self.load_balancer.id, + stats['loadbalancer_id']) + self.assertEqual(self.listener.id, stats['listener_id']) + self.assertEqual(self.amphora.id, stats['id']) + self.assertEqual(1, stats['bytes_in']) + self.assertEqual(2, stats['bytes_out']) + self.assertEqual(3, stats['active_connections']) + self.assertEqual(4, stats['total_connections']) + self.assertEqual(5, stats['request_errors']) + else: + self.assertEqual(self.load_balancer.id, + stats['loadbalancer_id']) + self.assertEqual(listener2_id, stats['listener_id']) + self.assertEqual(self.amphora.id, stats['id']) + self.assertEqual(6, stats['bytes_in']) + self.assertEqual(7, stats['bytes_out']) + self.assertEqual(8, stats['active_connections']) + self.assertEqual(9, stats['total_connections']) + self.assertEqual(10, stats['request_errors']) + class PoolRepositoryTest(BaseRepositoryTest): diff --git a/releasenotes/notes/per-amphora-statistics-api-5479605c7f3adb12.yaml b/releasenotes/notes/per-amphora-statistics-api-5479605c7f3adb12.yaml new file mode 100644 index 0000000000..af7566db7a --- /dev/null +++ b/releasenotes/notes/per-amphora-statistics-api-5479605c7f3adb12.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Adds an administrator API to access per-amphora statistics.