diff --git a/samtranslator/feature_toggle/dialup.py b/samtranslator/feature_toggle/dialup.py new file mode 100644 index 000000000..ad48e5131 --- /dev/null +++ b/samtranslator/feature_toggle/dialup.py @@ -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 diff --git a/samtranslator/feature_toggle/feature_toggle.py b/samtranslator/feature_toggle/feature_toggle.py index 6e665390c..2eddcfa1e 100644 --- a/samtranslator/feature_toggle/feature_toggle.py +++ b/samtranslator/feature_toggle/feature_toggle.py @@ -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 + "/..") @@ -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 diff --git a/samtranslator/translator/translator.py b/samtranslator/translator/translator.py index d4ca78068..474471452 100644 --- a/samtranslator/translator/translator.py +++ b/samtranslator/translator/translator.py @@ -91,7 +91,11 @@ def translate(self, sam_template, parameter_values, feature_toggle=None): :returns: a copy of the template with SAM resources replaced with the corresponding CloudFormation, which may \ be dumped into a valid CloudFormation JSON or YAML template """ - self.feature_toggle = feature_toggle if feature_toggle else FeatureToggle(FeatureToggleDefaultConfigProvider()) + self.feature_toggle = ( + feature_toggle + if feature_toggle + else FeatureToggle(FeatureToggleDefaultConfigProvider(), stage=None, account_id=None, region=None) + ) self.function_names = dict() self.redeploy_restapi_parameters = dict() sam_parameter_values = SamParameterValues(parameter_values) diff --git a/tests/feature_toggle/input/feature_toggle_config.json b/tests/feature_toggle/input/feature_toggle_config.json index d4b8ebabf..710f42cff 100644 --- a/tests/feature_toggle/input/feature_toggle_config.json +++ b/tests/feature_toggle/input/feature_toggle_config.json @@ -2,13 +2,20 @@ "__note__": "This is a dummy config for local testing. Any change here need to be migrated to SAM service.", "feature-1": { "beta": { - "us-west-2": {"enabled": true}, - "default": {"enabled": false}, - "123456789123": {"us-west-2": {"enabled": true}, "default": {"enabled": false}} + "us-west-2": {"type": "toggle", "enabled": true}, + "us-east-1": {"type": "account-percentile", "enabled-%": 10}, + "default": {"type": "toggle", "enabled": false}, + "123456789123": { + "us-west-2": {"type": "toggle", "enabled": true}, + "default": {"type": "toggle", "enabled": false} + } }, "gamma": { - "default": {"enabled": false}, - "123456789123": {"us-east-1": {"enabled": false}, "default": {"enabled": false}} + "default": {"type": "toggle", "enabled": false}, + "123456789123": { + "us-east-1": {"type": "toggle", "enabled": false}, + "default": {"type": "toggle", "enabled": false} + } }, "prod": {"default": {"enabled": false}} } diff --git a/tests/feature_toggle/test_dialup.py b/tests/feature_toggle/test_dialup.py new file mode 100644 index 000000000..2a613d862 --- /dev/null +++ b/tests/feature_toggle/test_dialup.py @@ -0,0 +1,65 @@ +from unittest import TestCase + +from parameterized import parameterized, param +from samtranslator.feature_toggle.dialup import * + + +class TestBaseDialup(TestCase): + def test___str__(self): + region_config = {} + dialup = BaseDialup(region_config) + self.assertEqual(str(dialup), "BaseDialup") + + +class TestDisabledDialup(TestCase): + def test_is_enabled(self): + region_config = {} + dialup = DisabledDialup(region_config) + self.assertFalse(dialup.is_enabled()) + + +class TestToggleDialUp(TestCase): + @parameterized.expand( + [ + param({"type": "toggle", "enabled": True}, True), + param({"type": "toggle", "enabled": False}, False), + param({"type": "toggle"}, False), # missing "enabled" key + ] + ) + def test_is_enabled(self, region_config, expected): + dialup = ToggleDialup(region_config) + self.assertEqual(dialup.is_enabled(), expected) + + +class TestSimpleAccountPercentileDialup(TestCase): + @parameterized.expand( + [ + param({"type": "account-percentile", "enabled-%": 10}, "feature-1", "123456789100", True), + param({"type": "account-percentile", "enabled-%": 10}, "feautre-1", "123456789123", False), + param({"type": "account-percentile", "enabled": True}, "feature-1", "123456789100", False), + ] + ) + def test_is_enabled(self, region_config, feature_name, account_id, expected): + dialup = SimpleAccountPercentileDialup( + region_config=region_config, + account_id=account_id, + feature_name=feature_name, + ) + self.assertEqual(dialup.is_enabled(), expected) + + @parameterized.expand( + [ + param("feature-1", "123456789123"), + param("feature-2", "000000000000"), + param("feature-3", "432187654321"), + param("feature-4", "111222333444"), + ] + ) + def test__get_account_percentile(self, account_id, feature_name): + region_config = {"type": "account-percentile", "enabled-%": 10} + dialup = SimpleAccountPercentileDialup( + region_config=region_config, + account_id=account_id, + feature_name=feature_name, + ) + self.assertTrue(0 <= dialup._get_account_percentile() < 100) diff --git a/tests/feature_toggle/test_feature_toggle.py b/tests/feature_toggle/test_feature_toggle.py index c4df7d683..56a432863 100644 --- a/tests/feature_toggle/test_feature_toggle.py +++ b/tests/feature_toggle/test_feature_toggle.py @@ -8,6 +8,7 @@ FeatureToggleLocalConfigProvider, FeatureToggleAppConfigConfigProvider, ) +from samtranslator.feature_toggle.dialup import ToggleDialup, SimpleAccountPercentileDialup, DisabledDialup my_path = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, my_path + "/..") @@ -16,87 +17,112 @@ class TestFeatureToggle(TestCase): @parameterized.expand( [ - param("feature-1", "beta", "default", False), - param("feature-1", "beta", "us-west-2", True), - param("feature-2", "beta", "us-west-2", False), # because feature is missing + param("feature-1", "beta", "default", "123456789123", False), + param("feature-1", "beta", "us-west-2", "123456789123", True), + param("feature-2", "beta", "us-west-2", "123456789123", False), # because feature is missing + param("feature-1", "beta", "ap-south-1", "123456789124", False), # because default is used + param("feature-1", "alpha", "us-east-1", "123456789123", False), # non-exist stage + param("feature-1", "beta", "us-east-1", "123456789100", True), + param("feature-1", "beta", "us-east-1", "123456789123", False), + # any None for stage, region and account_id should return False + param("feature-1", None, None, None, False), + param("feature-1", "beta", None, None, False), + param("feature-1", "beta", "us-west-2", None, False), + param("feature-1", "beta", None, "123456789123", False), ] ) - def test_feature_toggle_with_local_provider_for_stage(self, feature_name, stage, region, expected): + def test_feature_toggle_with_local_provider(self, feature_name, stage, region, account_id, expected): feature_toggle = FeatureToggle( - FeatureToggleLocalConfigProvider(os.path.join(my_path, "input", "feature_toggle_config.json")) + FeatureToggleLocalConfigProvider(os.path.join(my_path, "input", "feature_toggle_config.json")), + stage=stage, + region=region, + account_id=account_id, ) - self.assertEqual(feature_toggle.is_enabled_for_stage_in_region(feature_name, stage, region), expected) + self.assertEqual(feature_toggle.is_enabled(feature_name), expected) @parameterized.expand( [ - param("feature-1", "beta", "default", "123456789123", False), - param("feature-1", "beta", "us-west-2", "123456789123", True), - param("feature-2", "beta", "us-west-2", "123456789124", False), # because feature is missing + param("toggle", ToggleDialup), + param("account-percentile", SimpleAccountPercentileDialup), + param("something-else", DisabledDialup), ] ) - def test_feature_toggle_with_local_provider_for_account_id(self, feature_name, stage, region, account_id, expected): + def test__get_dialup(self, dialup_type, expected_class): feature_toggle = FeatureToggle( - FeatureToggleLocalConfigProvider(os.path.join(my_path, "input", "feature_toggle_config.json")) - ) - self.assertEqual( - feature_toggle.is_enabled_for_account_in_region(feature_name, stage, account_id, region), expected + FeatureToggleLocalConfigProvider(os.path.join(my_path, "input", "feature_toggle_config.json")), + stage=None, + region=None, + account_id=None, ) + region_config = {"type": dialup_type} + dialup = feature_toggle._get_dialup(region_config, "some-feature") + self.assertIsInstance(dialup, expected_class) class TestFeatureToggleAppConfig(TestCase): def setUp(self): self.content_stream_mock = Mock() self.content_stream_mock.read.return_value = b""" - { - "feature-1": { - "beta": { - "us-west-2": {"enabled": true}, - "default": {"enabled": false}, - "123456789123": {"us-west-2": {"enabled": true}, "default": {"enabled": false}} - }, - "gamma": { - "default": {"enabled": false}, - "123456789123": {"us-east-1": {"enabled": false}, "default": {"enabled": false}} - }, - "prod": {"default": {"enabled": false}} + { + "feature-1": { + "beta": { + "us-west-2": {"type": "toggle", "enabled": true}, + "us-east-1": {"type": "account-percentile", "enabled-%": 10}, + "default": {"type": "toggle", "enabled": false}, + "123456789123": { + "us-west-2": {"type": "toggle", "enabled": true}, + "default": {"type": "toggle", "enabled": false} + } + }, + "gamma": { + "default": {"type": "toggle", "enabled": false}, + "123456789123": { + "us-east-1": {"type": "toggle", "enabled": false}, + "default": {"type": "toggle", "enabled": false} + } + }, + "prod": {"default": {"type": "toggle", "enabled": false}} + } } - } """ self.app_config_mock = Mock() self.app_config_mock.get_configuration.return_value = {"Content": self.content_stream_mock} - @parameterized.expand( - [ - param("feature-1", "beta", "default", False), - param("feature-1", "beta", "us-west-2", True), - param("feature-2", "beta", "us-west-2", False), # because feature is missing - ] - ) - @patch("samtranslator.feature_toggle.feature_toggle.boto3") - def test_feature_toggle_for_stage(self, feature_name, stage, region, expected, boto3_mock): - boto3_mock.client.return_value = self.app_config_mock - feature_toggle_config_provider = FeatureToggleAppConfigConfigProvider( - "test_app_id", "test_env_id", "test_conf_id" - ) - feature_toggle = FeatureToggle(feature_toggle_config_provider) - self.assertEqual(feature_toggle.is_enabled_for_stage_in_region(feature_name, stage, region), expected) - @parameterized.expand( [ param("feature-1", "beta", "default", "123456789123", False), param("feature-1", "beta", "us-west-2", "123456789123", True), - param("feature-2", "beta", "us-west-2", "123456789124", False), # because feature is missing + param("feature-2", "beta", "us-west-2", "123456789123", False), # because feature is missing + param("feature-1", "beta", "ap-south-1", "123456789124", False), # because default is used + param("feature-1", "alpha", "us-east-1", "123456789123", False), # non-exist stage + param("feature-1", "beta", "us-east-1", "123456789100", True), + param("feature-1", "beta", "us-east-1", "123456789123", False), + # any None for stage, region and account_id returns False + param("feature-1", None, None, None, False), + param("feature-1", "beta", None, None, False), + param("feature-1", "beta", "us-west-2", None, False), + param("feature-1", "beta", None, "123456789123", False), ] ) @patch("samtranslator.feature_toggle.feature_toggle.boto3") - def test_feature_toggle_with_local_provider_for_account_id( + def test_feature_toggle_with_appconfig_provider( self, feature_name, stage, region, account_id, expected, boto3_mock ): boto3_mock.client.return_value = self.app_config_mock feature_toggle_config_provider = FeatureToggleAppConfigConfigProvider( "test_app_id", "test_env_id", "test_conf_id" ) - feature_toggle = FeatureToggle(feature_toggle_config_provider) - self.assertEqual( - feature_toggle.is_enabled_for_account_in_region(feature_name, stage, account_id, region), expected + feature_toggle = FeatureToggle( + feature_toggle_config_provider, stage=stage, region=region, account_id=account_id + ) + self.assertEqual(feature_toggle.is_enabled(feature_name), expected) + + +class TestFeatureToggleAppConfigConfigProvider(TestCase): + @patch("samtranslator.feature_toggle.feature_toggle.boto3") + def test_feature_toggle_with_exception(self, boto3_mock): + boto3_mock.client.raiseError.side_effect = Exception() + feature_toggle_config_provider = FeatureToggleAppConfigConfigProvider( + "test_app_id", "test_env_id", "test_conf_id" ) + self.assertEqual(feature_toggle_config_provider.config, {})