Skip to content
Merged
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
5 changes: 4 additions & 1 deletion bin/sam-translate.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ def transform_template(input_file_path, output_file_path):
feature_toggle = FeatureToggle(
FeatureToggleLocalConfigProvider(
os.path.join(my_path, "..", "tests", "feature_toggle", "input", "feature_toggle_config.json")
)
),
stage=None,
account_id=None,
region=None,
)
cloud_formation_template = transform(sam_template, {}, ManagedPolicyLoader(iam_client), feature_toggle)
cloud_formation_template_prettified = json.dumps(cloud_formation_template, indent=2)
Expand Down
2 changes: 1 addition & 1 deletion docs/safe_lambda_deployments.rst
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ NOTE: Verify that your AWS SDK version supports PutLifecycleEventHookExecutionSt

.. _PutLifecycleEventHookExecutionStatus: https://docs.aws.amazon.com/codedeploy/latest/APIReference/API_PutLifecycleEventHookExecutionStatus.html

.. _Here: https://github.com/awslabs/serverless-application-model/blob/master/examples/2016-10-31/lambda_safe_deployments/src/preTrafficHook.js
.. _Here: https://github.com/aws/serverless-application-model/blob/d168f371f494196a57032313075db9faae5587e4/examples/2016-10-31/lambda_safe_deployments/src/preTrafficHook.js

Traffic Shifting Configurations
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
27 changes: 25 additions & 2 deletions integration/helpers/base_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import logging
import os

Expand Down Expand Up @@ -146,6 +147,28 @@ def create_and_verify_stack(self, file_name, parameters=None):
self.deploy_stack(parameters)
self.verify_stack()

def update_and_verify_stack(self, file_name, parameters=None):
"""
Updates the Cloud Formation stack and verifies it against the expected
result

Parameters
----------
file_name : string
Template file name
parameters : list
List of parameters
"""
if not self.stack_name:
raise Exception("Stack not created.")
self.output_file_path = str(Path(self.output_dir, "cfn_" + file_name + ".yaml"))
self.expected_resource_path = str(Path(self.expected_dir, file_name + ".json"))

self._fill_template(file_name)
self.transform_template()
self.deploy_stack(parameters)
self.verify_stack(end_state="UPDATE_COMPLETE")

def transform_template(self):
transform_template(self.sub_input_file_path, self.output_file_path)

Expand Down Expand Up @@ -342,12 +365,12 @@ def deploy_stack(self, parameters=None):
self.stack_description = self.client_provider.cfn_client.describe_stacks(StackName=self.stack_name)
self.stack_resources = self.client_provider.cfn_client.list_stack_resources(StackName=self.stack_name)

def verify_stack(self):
def verify_stack(self, end_state="CREATE_COMPLETE"):
"""
Gets and compares the Cloud Formation stack against the expect result file
"""
# verify if the stack was successfully created
self.assertEqual(self.stack_description["Stacks"][0]["StackStatus"], "CREATE_COMPLETE")
self.assertEqual(self.stack_description["Stacks"][0]["StackStatus"], end_state)
# verify if the stack contains the expected resources
error = verify_stack_resources(self.expected_resource_path, self.stack_resources)
if error:
Expand Down
11 changes: 11 additions & 0 deletions integration/resources/expected/single/basic_api_with_mode.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[
{"LogicalResourceId": "MyApi", "ResourceType": "AWS::ApiGateway::RestApi"},
{"LogicalResourceId": "MyApiDeploymenta808f15210", "ResourceType": "AWS::ApiGateway::Deployment"},
{"LogicalResourceId": "MyApiMyNewStageNameStage", "ResourceType": "AWS::ApiGateway::Stage"},
{"LogicalResourceId": "TestFunction", "ResourceType": "AWS::Lambda::Function"},
{"LogicalResourceId": "TestFunctionAliaslive", "ResourceType": "AWS::Lambda::Alias"},
{"LogicalResourceId": "TestFunctionGetPermissionMyNewStageName", "ResourceType": "AWS::Lambda::Permission"},
{"LogicalResourceId": "TestFunctionPutPermissionMyNewStageName", "ResourceType": "AWS::Lambda::Permission"},
{"LogicalResourceId": "TestFunctionRole", "ResourceType": "AWS::IAM::Role"},
{"LogicalResourceId": "TestFunctionVersione9898fd501", "ResourceType": "AWS::Lambda::Version"}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[
{"LogicalResourceId": "MyApi", "ResourceType": "AWS::ApiGateway::RestApi"},
{"LogicalResourceId": "MyApiDeploymentada889e3ac", "ResourceType": "AWS::ApiGateway::Deployment"},
{"LogicalResourceId": "MyApiMyNewStageNameStage", "ResourceType": "AWS::ApiGateway::Stage"},
{"LogicalResourceId": "TestFunction", "ResourceType": "AWS::Lambda::Function"},
{"LogicalResourceId": "TestFunctionAliaslive", "ResourceType": "AWS::Lambda::Alias"},
{"LogicalResourceId": "TestFunctionPutPermissionMyNewStageName", "ResourceType": "AWS::Lambda::Permission"},
{"LogicalResourceId": "TestFunctionRole", "ResourceType": "AWS::IAM::Role"},
{"LogicalResourceId": "TestFunctionVersion847aaa5fc1", "ResourceType": "AWS::Lambda::Version"}
]
34 changes: 34 additions & 0 deletions integration/resources/templates/single/basic_api_with_mode.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
Resources:
MyApi:
Type: AWS::Serverless::Api
Properties:
StageName: MyNewStageName
Mode: overwrite

TestFunction:
Type: 'AWS::Serverless::Function'
Properties:
Handler: index.handler
Runtime: python3.6
AutoPublishAlias: live
InlineCode: |
import json
def handler(event, context):
return {'statusCode': 200, 'body': json.dumps('Hello World!')}
Events:
Get:
Type: Api
Properties:
Path: /get
Method: get
RestApiId: !Ref MyApi
Put:
Type: Api
Properties:
Path: /put
Method: put
RestApiId: !Ref MyApi

Outputs:
ApiEndpoint:
Value: !Sub "https://${MyApi}.execute-api.${AWS::Region}.amazonaws.com/MyNewStageName"
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
Resources:
MyApi:
Type: AWS::Serverless::Api
Properties:
StageName: MyNewStageName
Mode: overwrite

TestFunction:
Type: 'AWS::Serverless::Function'
Properties:
Handler: index.handler
Runtime: python3.6
AutoPublishAlias: live
InlineCode: |
def handler(event, context):
print("Hello, world!")
Events:
Put:
Type: Api
Properties:
Path: /put
Method: put
RestApiId: !Ref MyApi

19 changes: 19 additions & 0 deletions integration/single/test_basic_api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from integration.helpers.base_test import BaseTest
import requests


class TestBasicApi(BaseTest):
Expand All @@ -24,6 +25,24 @@ def test_basic_api(self):

self.assertEqual(len(set(first_dep_ids).intersection(second_dep_ids)), 0)

def test_basic_api_with_mode(self):
"""
Creates an API and updates its DefinitionUri
"""
# Create an API with get and put
self.create_and_verify_stack("basic_api_with_mode")

stack_output = self.get_stack_outputs()
api_endpoint = stack_output.get("ApiEndpoint")
response = requests.get(f"{api_endpoint}/get")
self.assertEqual(response.status_code, 200)

# Removes get from the API
self.update_and_verify_stack("basic_api_with_mode_update")
response = requests.get(f"{api_endpoint}/get")
# API Gateway by default returns 403 if a path do not exist
self.assertEqual(response.status_code, 403)

def test_basic_api_inline_openapi(self):
"""
Creates an API with and inline OpenAPI and updates its DefinitionBody basePath
Expand Down
2 changes: 1 addition & 1 deletion samtranslator/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.37.0"
__version__ = "1.38.0"
74 changes: 74 additions & 0 deletions samtranslator/feature_toggle/dialup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import hashlib


class BaseDialup(object):
"""BaseDialup class to provide an interface for all dialup classes"""

def __init__(self, region_config, **kwargs):
self.region_config = region_config

def is_enabled(self):
"""
Returns a bool on whether this dialup is enabled or not
"""
raise NotImplementedError

def __str__(self):
return self.__class__.__name__


class DisabledDialup(BaseDialup):
"""
A dialup that is never enabled
"""

def __init__(self, region_config, **kwargs):
super(DisabledDialup, self).__init__(region_config)

def is_enabled(self):
return False


class ToggleDialup(BaseDialup):
"""
A simple toggle Dialup
Example of region_config: { "type": "toggle", "enabled": True }
"""

def __init__(self, region_config, **kwargs):
super(ToggleDialup, self).__init__(region_config)
self.region_config = region_config

def is_enabled(self):
return self.region_config.get("enabled", False)


class SimpleAccountPercentileDialup(BaseDialup):
"""
Simple account percentile dialup, enabling X% of
Example of region_config: { "type": "account-percentile", "enabled-%": 20 }
"""

def __init__(self, region_config, account_id, feature_name, **kwargs):
super(SimpleAccountPercentileDialup, self).__init__(region_config)
self.account_id = account_id
self.feature_name = feature_name

def _get_account_percentile(self):
"""
Get account percentile based on sha256 hash of account ID and feature_name
:returns: integer n, where 0 <= n < 100
"""
m = hashlib.sha256()
m.update(self.account_id.encode())
m.update(self.feature_name.encode())
return int(m.hexdigest(), 16) % 100

def is_enabled(self):
"""
Enable when account_percentile falls within target_percentile
Meaning only (target_percentile)% of accounts will be enabled
"""
target_percentile = self.region_config.get("enabled-%", 0)
return self._get_account_percentile() < target_percentile
83 changes: 54 additions & 29 deletions samtranslator/feature_toggle/feature_toggle.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@
import json
import boto3
import logging
import hashlib

from botocore.config import Config
from samtranslator.feature_toggle.dialup import (
DisabledDialup,
ToggleDialup,
SimpleAccountPercentileDialup,
)

my_path = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, my_path + "/..")
Expand All @@ -18,50 +24,69 @@ class FeatureToggle:
SAM is executing or not.
"""

def __init__(self, config_provider):
DIALUP_RESOLVER = {
"toggle": ToggleDialup,
"account-percentile": SimpleAccountPercentileDialup,
}

def __init__(self, config_provider, stage, account_id, region):
self.feature_config = config_provider.config
self.stage = stage
self.account_id = account_id
self.region = region

def is_enabled_for_stage_in_region(self, feature_name, stage, region="default"):
def _get_dialup(self, region_config, feature_name):
"""
To check if feature is available for a particular stage or not.
:param feature_name: name of feature
:param stage: stage where SAM is running
:param region: region in which SAM is running
:return:
get the right dialup instance
if no dialup type is provided or the specified dialup is not supported,
an instance of DisabledDialup will be returned

:param region_config: region config
:param feature_name: feature_name
:return: an instance of
"""
if feature_name not in self.feature_config:
LOG.warning("Feature '{}' not available in Feature Toggle Config.".format(feature_name))
return False
stage_config = self.feature_config.get(feature_name, {}).get(stage, {})
if not stage_config:
LOG.info("Stage '{}' not enabled for Feature '{}'.".format(stage, feature_name))
return False
region_config = stage_config.get(region, {}) if region in stage_config else stage_config.get("default", {})
is_enabled = region_config.get("enabled", False)
LOG.info("Feature '{}' is enabled: '{}'".format(feature_name, is_enabled))
return is_enabled
dialup_type = region_config.get("type")
if dialup_type in FeatureToggle.DIALUP_RESOLVER:
return FeatureToggle.DIALUP_RESOLVER[dialup_type](
region_config, account_id=self.account_id, feature_name=feature_name
)
LOG.warning("Dialup type '{}' is None or is not supported.".format(dialup_type))
return DisabledDialup(region_config)

def is_enabled_for_account_in_region(self, feature_name, stage, account_id, region="default"):
def is_enabled(self, feature_name):
"""
To check if feature is available for a particular account or not.
To check if feature is available

:param feature_name: name of feature
:param stage: stage where SAM is running
:param account_id: account_id who is executing SAM template
:param region: region in which SAM is running
:return:
"""
if feature_name not in self.feature_config:
LOG.warning("Feature '{}' not available in Feature Toggle Config.".format(feature_name))
return False

stage = self.stage
region = self.region
account_id = self.account_id
if not stage or not region or not account_id:
LOG.warning(
"One or more of stage, region and account_id is not set. Feature '{}' not enabled.".format(feature_name)
)
return False

stage_config = self.feature_config.get(feature_name, {}).get(stage, {})
if not stage_config:
LOG.info("Stage '{}' not enabled for Feature '{}'.".format(stage, feature_name))
return False
account_config = stage_config.get(account_id) if account_id in stage_config else stage_config.get("default", {})
region_config = (
account_config.get(region, {}) if region in account_config else account_config.get("default", {})
)
is_enabled = region_config.get("enabled", False)

if account_id in stage_config:
account_config = stage_config[account_id]
region_config = account_config[region] if region in account_config else account_config.get("default", {})
else:
region_config = stage_config[region] if region in stage_config else stage_config.get("default", {})

dialup = self._get_dialup(region_config, feature_name=feature_name)
LOG.info("Using Dialip {}".format(dialup))
is_enabled = dialup.is_enabled()

LOG.info("Feature '{}' is enabled: '{}'".format(feature_name, is_enabled))
return is_enabled

Expand Down
Loading