Skip to content

Commit

Permalink
Release/v1.38.0 (#2082) (#2095)
Browse files Browse the repository at this point in the history
* test: New test group to test for side effects (#2046)

* test: New test group to test for side effects

* refactor: Updated to use _compare_transform and test CN and GOV partitions

* docs: fix dead link (#2045)

* Percentage-based Enablement for Feature Toggle (#1952)

* Percentage-based Enablement for Feature Toggle

* Update Feature Toggle to accept stage, account_id and region during instanciation

* remove unnecessary uses of dict.get method

* Refactor feature toggle methods

* Update test names

* black reformat

* Update FeatureToggle to require stage, region and account_id to instanciate

* Update log message

* Implement calculating account percentile based on hash of account_id and feature_name

* Refactor _is_feature_enabled_for_region_config

* Refactor dialup logic into its own classes

* Add comments for dialup classes

* Rename NeverEnabledDialup to DisabledDialup

* chore(tests): Adding any tests (#2053)

* Adding api_request_model any tests

* Add any to api_request_model_openapi_3 cases

* Add rest of relevant any test cases

* Fix hashing to match python2

* add api_with_swagger_authorizer_none to be run

* fix py2 hashes in api_with_swagger_authorizer_none tests

Co-authored-by: Jacob Fuss <[email protected]>

* Add modes support for RestApi (#2055)

* Adding Mode passthrough property to RestApi with unit tests.

* Adding integration test for Mode

* Fixing sam-translate for manual translation.

* running black formatting

* Running black formatting, again.

* Clearing pip-wheel-metadata.

* Clearing tmp folder created by integ test.

Co-authored-by: Tarun Mall <[email protected]>

* chore: bump version to 1.38.0 (#2081)

Co-authored-by: Mathieu Grandis <[email protected]>
Co-authored-by: Chris Rehn <[email protected]>
Co-authored-by: Wing Fung Lau <[email protected]>
Co-authored-by: Jacob Fuss <[email protected]>
Co-authored-by: Jacob Fuss <[email protected]>
Co-authored-by: Tarun Mall <[email protected]>
Co-authored-by: Raymond Wang <[email protected]>

Co-authored-by: Mathieu Grandis <[email protected]>
Co-authored-by: Chris Rehn <[email protected]>
Co-authored-by: Wing Fung Lau <[email protected]>
Co-authored-by: Jacob Fuss <[email protected]>
Co-authored-by: Jacob Fuss <[email protected]>
Co-authored-by: Tarun Mall <[email protected]>
Co-authored-by: Raymond Wang <[email protected]>
  • Loading branch information
8 people authored Jul 19, 2021
1 parent 25fa8a8 commit 4ef6093
Show file tree
Hide file tree
Showing 147 changed files with 22,958 additions and 13,156 deletions.
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

0 comments on commit 4ef6093

Please sign in to comment.