Skip to content

Commit

Permalink
added endpoint for a stack's request count (zalando-stups#221)
Browse files Browse the repository at this point in the history
  • Loading branch information
fsander committed Feb 22, 2018
1 parent 65d2918 commit 4240517
Show file tree
Hide file tree
Showing 9 changed files with 294 additions and 0 deletions.
23 changes: 23 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 @@ -241,6 +242,28 @@ def get_stack_traffic(stack_id: str, region: str=None) -> Tuple[dict, int, dict]
'Stack not found: {}'.format(stack_id),
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)
running_time = MeasureRunningTime('get_stack_request_count.success')
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()

running_time.finish()
return connexion.problem(404, 'Not Found',
'Stack not found: {}'.format(stack_id),
headers=_make_headers())


@bouncer
@exception_to_connexion_problem
Expand Down
63 changes: 63 additions & 0 deletions lizzy/apps/aws.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from datetime import datetime, timedelta
from logging import getLogger

import boto3
from botocore.exceptions import ClientError

from lizzy.exceptions import ObjectNotFound


class AWS(object):

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):
cf = boto3.client("cloudformation", self.region)
try:
response = cf.describe_stack_resource(StackName=stack_id, LogicalResourceId="AppLoadBalancer")
lb_id = response['StackResourceDetail']['PhysicalResourceId']
lb_type = response['StackResourceDetail']['ResourceType']
return lb_id, lb_type
except ClientError as e:
msg = e.response.get('Error', {}).get('Message', 'Unknown')
if all(marker in msg for marker in [stack_id, 'does not exist']):
raise ObjectNotFound(msg)
else:
raise e

def get_request_count(self, lb_id: str, lb_type: str, minutes: int = 5):
cw = 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 = cw.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 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

0 comments on commit 4240517

Please sign in to comment.