diff --git a/lizzy/api.py b/lizzy/api.py index 99da657..e795093 100644 --- a/lizzy/api.py +++ b/lizzy/api.py @@ -9,6 +9,7 @@ from decorator import decorator from flask import Response from lizzy import config, metrics, sentry_client +from lizzy.apps.aws import AWS from lizzy.apps.senza import Senza from lizzy.exceptions import ExecutionError, ObjectNotFound, TrafficNotUpdated from lizzy.metrics import MeasureRunningTime @@ -242,6 +243,23 @@ def get_stack_traffic(stack_id: str, region: str=None) -> Tuple[dict, int, dict] headers=_make_headers()) +@bouncer +@exception_to_connexion_problem +def get_stack_request_count(stack_id: str, region: str = None, minutes: int = 5) -> Tuple[dict, int, dict]: + """ + GET /stacks/{id}/request_count + + """ + sentry_client.capture_breadcrumb(data={ + 'stack_id': stack_id, + 'region': region, + }) + aws = AWS(region or config.region) + lb_id, lb_type = aws.get_load_balancer_info(stack_id) + request_count = aws.get_request_count(lb_id, lb_type, minutes) + return {'request_count': request_count}, 200, _make_headers() + + @bouncer @exception_to_connexion_problem def delete_stack(stack_id: str, delete_options: dict) -> dict: diff --git a/lizzy/apps/aws.py b/lizzy/apps/aws.py new file mode 100644 index 0000000..60c4499 --- /dev/null +++ b/lizzy/apps/aws.py @@ -0,0 +1,82 @@ +""" +Provides convenient access to AWS resources by abstracting and wrapping boto3 calls. +""" +from datetime import datetime, timedelta +from logging import getLogger + +import boto3 +from botocore.exceptions import ClientError + +from lizzy.exceptions import ObjectNotFound + + +class AWS(object): + """ + Provides convenient access to AWS resources by abstracting and wrapping boto3 calls. + """ + def __init__(self, region: str): + super().__init__() + self.logger = getLogger('lizzy.app.aws') + self.region = region + + def get_load_balancer_info(self, stack_id: str): + """ + Resolves the name and type of a stack's load balancer. Raises ObjectNotFound exception if the specified stack + does not exist or the stack has no load balancer. Useful in combination with get_request_count + :param stack_id: The stack's id in the format: - + :return: (load_balancer_name, load_balancer_type) + """ + cloudformation = boto3.client("cloudformation", self.region) + try: + response = cloudformation.describe_stack_resource(StackName=stack_id, LogicalResourceId="AppLoadBalancer") + lb_id = str(response['StackResourceDetail']['PhysicalResourceId']) + lb_type = str(response['StackResourceDetail']['ResourceType']) + return lb_id, lb_type + except ClientError as error: + msg = error.response.get('Error', {}).get('Message', 'Unknown') + if all(marker in msg for marker in [stack_id, 'does not exist']): + raise ObjectNotFound(msg) + else: + raise error + + def get_request_count(self, lb_id: str, lb_type: str, minutes: int = 5) -> int: + """ + Resolves the request count as reported by AWS Cloudwatch for a given load balancer in the last n minutes. + Compatible with classic and application load balancers. + :param lb_id: the id/name of the load balancer + :param lb_type: either 'AWS::ElasticLoadBalancingV2::LoadBalancer' or 'AWS::ElasticLoadBalancing::LoadBalancer' + :param minutes: defines the time span to count requests in: now - minutes + :return: the number of request + """ + cloudwatch = boto3.client('cloudwatch', self.region) + end = datetime.utcnow() + start = end - timedelta(minutes=minutes) + kwargs = { + 'MetricName': 'RequestCount', + 'StartTime': start, + 'EndTime': end, + 'Period': 60 * minutes, + 'Statistics': ['Sum'] + } + if lb_type == 'AWS::ElasticLoadBalancingV2::LoadBalancer': + kwargs.update({ + 'Namespace': 'AWS/ApplicationELB', + 'Dimensions': [{ + 'Name': 'LoadBalancer', + 'Value': lb_id.split('/', 1)[1] + }] + }) + elif lb_type == 'AWS::ElasticLoadBalancing::LoadBalancer': + kwargs.update({ + 'Namespace': 'AWS/ELB', + 'Dimensions': [{ + 'Name': 'LoadBalancerName', + 'Value': lb_id + }] + }) + else: + raise Exception('unknown load balancer type: ' + lb_type) + metrics = cloudwatch.get_metric_statistics(**kwargs) + if len(metrics['Datapoints']) > 0: + return int(metrics['Datapoints'][0]['Sum']) + return 0 diff --git a/lizzy/swagger/lizzy.yaml b/lizzy/swagger/lizzy.yaml index cbaede1..dbc1098 100644 --- a/lizzy/swagger/lizzy.yaml +++ b/lizzy/swagger/lizzy.yaml @@ -326,6 +326,62 @@ paths: schema: $ref: '#/definitions/problem' + /stacks/{stack-id}/request_count: + get: + summary: Retrieves the request count of a lizzy stack as reported by the corresponding AWS laod balancer + description: Retrieves the request count of a lizzy stack by stack id + operationId: lizzy.api.get_stack_request_count + security: + - oauth: + - "{{deployer_scope}}" + parameters: + - name: stack-id + in: path + description: Stack Id + required: true + type: string + - name: region + in: query + type: string + pattern: "\\w{2}-\\w+-[0-9]" + description: Region of stack + required: false + - name: minutes + in: query + type: integer + default: 5 + minimum: 1 + description: The returned number of reqests occured in the last minutes as specified by this parameter + required: false + responses: + 200: + description: Request Count of lizzy stack + headers: + X-Lizzy-Version: + description: Lizzy Version + type: string + X-Senza-Version: + description: Senza Version + type: string + schema: + type: object + properties: + request_count: + type: integer + minimum: 0 + 404: + description: | + Stack was not found. + headers: + X-Lizzy-Version: + description: Lizzy Version + type: string + X-Senza-Version: + description: Senza Version + type: string + schema: + $ref: '#/definitions/problem' + /status: get: summary: Retrieves the application status diff --git a/requirements.txt b/requirements.txt index 3f4e01f..ea6f804 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ +botocore +boto3 connexion==1.1.5 environmental>=1.1 decorator diff --git a/setup.py b/setup.py index 3a9d7d0..102a433 100755 --- a/setup.py +++ b/setup.py @@ -45,6 +45,7 @@ def run_tests(self): errno = pytest.main(self.pytest_args) sys.exit(errno) + VERSION = '2017.0.dev1' setup( diff --git a/tests/conftest.py b/tests/conftest.py index 2a52575..6bda6ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import pytest from fixtures.senza import mock_senza # NOQA +from fixtures.aws import mock_aws # NOQA @pytest.fixture(scope='session') diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/aws.py b/tests/fixtures/aws.py new file mode 100644 index 0000000..d97d8b8 --- /dev/null +++ b/tests/fixtures/aws.py @@ -0,0 +1,12 @@ +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def mock_aws(monkeypatch): + mock = MagicMock() + mock.return_value = mock + + monkeypatch.setattr('lizzy.api.AWS', mock) + return mock diff --git a/tests/test_app.py b/tests/test_app.py index ee07072..208157f 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -561,3 +561,31 @@ def test_health_check_failing(app, mock_senza): response = app.get('/health') assert response.status_code == 500 + +def test_request_count(app, mock_aws): + mock_aws.get_load_balancer_info.return_value = 'lb-id', 'lb-type' + mock_aws.get_request_count.return_value = 3185 + + response = app.get('/api/stacks/stack_name-stack_version/request_count', headers=GOOD_HEADERS) + assert response.status_code == 200 + mock_aws.get_load_balancer_info.assert_called_with('stack_name-stack_version') + mock_aws.get_request_count.assert_called_with('lb-id', 'lb-type', 5) + + assert json.loads(response.data.decode()) == {'request_count': 3185} + +def test_request_count_set_minutes(app, mock_aws): + mock_aws.get_load_balancer_info.return_value = 'lb-id', 'lb-type' + mock_aws.get_request_count.return_value = 3185 + + response = app.get('/api/stacks/stack_name-stack_version/request_count?minutes=15', headers=GOOD_HEADERS) + assert response.status_code == 200 + mock_aws.get_load_balancer_info.assert_called_with('stack_name-stack_version') + mock_aws.get_request_count.assert_called_with('lb-id', 'lb-type', 15) + + assert json.loads(response.data.decode()) == {'request_count': 3185} + +def test_request_count_set_minutes_invalid(app, mock_aws): + mock_aws.get_request_count.return_value = 3185 + + response = app.get('/api/stacks/stack_name-stack_version/request_count?minutes=0', headers=GOOD_HEADERS) + assert response.status_code == 400 diff --git a/tests/test_aws.py b/tests/test_aws.py new file mode 100644 index 0000000..d7d0912 --- /dev/null +++ b/tests/test_aws.py @@ -0,0 +1,109 @@ +from unittest.mock import MagicMock + +import pytest +from botocore.exceptions import ClientError + +from lizzy.apps.aws import AWS +from lizzy.exceptions import (ObjectNotFound) + + +def test_get_load_balancer_info_expired_token(monkeypatch): + with pytest.raises(ClientError): + cf = MagicMock() + cf.describe_stack_resource.side_effect = ClientError( + {'Error': { + 'Code': 'ExpiredToken', + 'Message': 'The security token included in the request is expired' + }}, + 'DescribeStackResources' + ) + monkeypatch.setattr('boto3.client', lambda *args, **kwargs: cf) + aws = AWS('region') + aws.get_load_balancer_info('stack-id-version') + cf.describe_stack_resource.assert_called_with( + **{'StackName': 'stack-id-version', 'LogicalResourceId': 'AppLoadBalancer'} + ) + + +def test_get_load_balancer_info_stack_not_found(monkeypatch): + with pytest.raises(ObjectNotFound) as e: + cf = MagicMock() + msg = "Stack 'stack-id-version' does not exist" + cf.describe_stack_resource.side_effect = ClientError( + {'Error': { + 'Code': 'ValidationError', + 'Message': msg + }}, + 'DescribeStackResources' + ) + monkeypatch.setattr('boto3.client', lambda *args, **kwargs: cf) + aws = AWS('region') + aws.get_load_balancer_info('stack-id-version') + cf.describe_stack_resource.assert_called_with( + **{'StackName': 'stack-id-version', 'LogicalResourceId': 'AppLoadBalancer'} + ) + assert e.uid == msg + + +def test_get_load_balancer_info_stack_without_load_balancer(monkeypatch): + with pytest.raises(ObjectNotFound) as e: + cf = MagicMock() + msg = "Resource AppLoadBalancer does not exist for stack stack-id-version" + cf.describe_stack_resource.side_effect = ClientError( + {'Error': { + 'Code': 'ValidationError', + 'Message': msg + }}, + 'DescribeStackResources' + ) + monkeypatch.setattr('boto3.client', lambda *args, **kwargs: cf) + aws = AWS('region') + aws.get_load_balancer_info('stack-id-version') + cf.describe_stack_resource.assert_called_with( + **{'StackName': 'stack-id-version', 'LogicalResourceId': 'AppLoadBalancer'} + ) + assert e.uid == msg + + +def test_get_load_balancer_info_happy_path(monkeypatch): + cf = MagicMock() + cf.describe_stack_resource.return_value = { + 'StackResourceDetail': { + 'PhysicalResourceId': 'lb-id', + 'ResourceType': 'lb-type' + } + } + monkeypatch.setattr('boto3.client', lambda *args, **kwargs: cf) + aws = AWS('region') + lb_id, lb_type = aws.get_load_balancer_info('stack-id-version') + cf.describe_stack_resource.assert_called_with( + **{'StackName': 'stack-id-version', 'LogicalResourceId': 'AppLoadBalancer'} + ) + assert lb_id == 'lb-id' + assert lb_type == 'lb-type' + + +def test_get_request_count_invalid_lb_type(): + aws = AWS('region') + with pytest.raises(Exception) as e: + aws.get_request_count('lb-id', 'invalid-lb-type') + assert e.msg == 'unknown load balancer type: invalid-lb-type' + + +@pytest.mark.parametrize( + 'elb_name, elb_type, response, expected_result', + [ + ('lb_name', 'AWS::ElasticLoadBalancing::LoadBalancer', {'Datapoints': [{'Sum': 4176}]}, 4176), + ('lb_name', 'AWS::ElasticLoadBalancing::LoadBalancer', {'Datapoints': []}, 0), + ('arn:aws:cf:region:account:stack/stack-id-version/uuid', 'AWS::ElasticLoadBalancingV2::LoadBalancer', + {'Datapoints': [{'Sum': 94374}]}, 94374), + ('arn:aws:cf:region:account:stack/stack-id-version/uuid', 'AWS::ElasticLoadBalancingV2::LoadBalancer', + {'Datapoints': []}, 0), + ]) +def test_get_load_balancer_with_classic_lb_sum_present(monkeypatch, elb_name, elb_type, response, expected_result): + cw = MagicMock() + cw.get_metric_statistics.return_value = response + monkeypatch.setattr('boto3.client', lambda *args, **kwargs: cw) + aws = AWS('region') + request_count = aws.get_request_count(elb_name, elb_type) + assert request_count == expected_result