Skip to content
This repository has been archived by the owner on Feb 4, 2022. It is now read-only.

added endpoint for a stack's request count (#221) #223

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions lizzy/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
82 changes: 82 additions & 0 deletions lizzy/apps/aws.py
Original file line number Diff line number Diff line change
@@ -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: <stack_name>-<stack_version>
: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
56 changes: 56 additions & 0 deletions lizzy/swagger/lizzy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
botocore
boto3
connexion==1.1.5
environmental>=1.1
decorator
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def run_tests(self):
errno = pytest.main(self.pytest_args)
sys.exit(errno)


VERSION = '2017.0.dev1'

setup(
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pytest

from fixtures.senza import mock_senza # NOQA
from fixtures.aws import mock_aws # NOQA


@pytest.fixture(scope='session')
Expand Down
Empty file added tests/fixtures/__init__.py
Empty file.
12 changes: 12 additions & 0 deletions tests/fixtures/aws.py
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
109 changes: 109 additions & 0 deletions tests/test_aws.py
Original file line number Diff line number Diff line change
@@ -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