Skip to content

Commit

Permalink
Percentage-based Enablement for Feature Toggle (aws#1952)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
hawflau authored and Tarun K. Mall committed Jul 7, 2021
1 parent d6d4f52 commit 240f820
Show file tree
Hide file tree
Showing 6 changed files with 284 additions and 83 deletions.
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
6 changes: 5 additions & 1 deletion samtranslator/translator/translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,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)
Expand Down
17 changes: 12 additions & 5 deletions tests/feature_toggle/input/feature_toggle_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
}
Expand Down
65 changes: 65 additions & 0 deletions tests/feature_toggle/test_dialup.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit 240f820

Please sign in to comment.