Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Percentage-based Enablement for Feature Toggle #1952

Merged
65 changes: 65 additions & 0 deletions samtranslator/feature_toggle/dialup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import hashlib


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

def is_enabled(self):
raise NotImplementedError

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


class NeverEnabledDialup(BaseDialup):
hawflau marked this conversation as resolved.
Show resolved Hide resolved
"""
A dialup that is never enabled
"""

def __init__(self, region_config, **kwargs):
super(NeverEnabledDialup, 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):
target_percentile = self.region_config.get("enabled-%", 0)
return self._get_account_percentile() < target_percentile
63 changes: 31 additions & 32 deletions samtranslator/feature_toggle/feature_toggle.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
import hashlib

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

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

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 _get_dialup(self, region_config, feature_name):
"""
get the right dialup instance
if no dialup type is provided or the specified dialup is not supported,
an instance of NeverEnabledDialup will be returned

:param region_config: region config
:param feature_name: feature_name
:return: an instance of
"""
dialup_type = region_config.get("type")
if dialup_type in FeatureToggle.DIALUP_RESOLVER:
hawflau marked this conversation as resolved.
Show resolved Hide resolved
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 NeverEnabledDialup(region_config)

def is_enabled(self, feature_name):
"""
To check if feature is available
Expand Down Expand Up @@ -55,42 +83,13 @@ def is_enabled(self, feature_name):
else:
region_config = stage_config[region] if region in stage_config else stage_config.get("default", {})

is_enabled = self._is_feature_enabled_for_region_config(feature_name, region_config)
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

def _get_account_percentile(self, feature_name):
"""
Get account percentile based on sha256 hash of account ID and feature_name

:param feature_name: name of feature
:returns: integer n, where 0 <= n < 100
"""
m = hashlib.sha256()
m.update(self.account_id.encode())
m.update(feature_name.encode())
return int(m.hexdigest(), 16) % 100

def _is_feature_enabled_for_region_config(self, feature_name, region_config):
"""
returns if a feature is enabled for a given config

:params feature_name: name of feature
:params region_config: region config obtained from stage_config or account_config
:returns: bool is_enabled
"""
if "enabled-%" in region_config:
# Percentage-based enablement
# if target_percentile = 10 => account_percentile < 10 means the account is the selected 10%
target_percentile = region_config["enabled-%"]
account_percentile = self._get_account_percentile(feature_name)
is_enabled = account_percentile < target_percentile
else:
is_enabled = region_config.get("enabled", False)

return is_enabled


class FeatureToggleConfigProvider:
"""Interface for all FeatureToggle config providers"""
Expand Down
18 changes: 12 additions & 6 deletions tests/feature_toggle/input/feature_toggle_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +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},
"us-east-1": {"enabled-%": 10},
"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 TestNeverEnabledDialup(TestCase):
def test_is_enabled(self):
region_config = {}
dialup = NeverEnabledDialup(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)
71 changes: 29 additions & 42 deletions tests/feature_toggle/test_feature_toggle.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
FeatureToggleLocalConfigProvider,
FeatureToggleAppConfigConfigProvider,
)
from samtranslator.feature_toggle.dialup import ToggleDialup, SimpleAccountPercentileDialup, NeverEnabledDialup

my_path = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, my_path + "/..")
Expand Down Expand Up @@ -41,62 +42,48 @@ def test_feature_toggle_with_local_provider(self, feature_name, stage, region, a

@parameterized.expand(
[
param("123456789123", "feature-1"),
param("000000000000", "feature-2"),
param("432187654321", "feature-3"),
param("111222333444", "feature-4"),
param("toggle", ToggleDialup),
param("account-percentile", SimpleAccountPercentileDialup),
param("something-else", NeverEnabledDialup),
]
)
def test__get_account_percentile(self, account_id, feature_name):
def test__get_dialup(self, dialup_type, expected_class):
feature_toggle = FeatureToggle(
FeatureToggleLocalConfigProvider(os.path.join(my_path, "input", "feature_toggle_config.json")),
stage=None,
region=None,
account_id=account_id,
)
self.assertTrue(0 <= feature_toggle._get_account_percentile(feature_name) < 100)

@parameterized.expand(
[
param({"enabled": True}, 0, True),
param({"enabled-%": 10}, 0, True),
param({"enabled": False}, 0, False),
param({"enabled-%": 10}, 20, False),
]
)
@patch.object(FeatureToggle, "_get_account_percentile")
def test__is_feature_enabled_for_region_config(
self, region_config, account_percentile, expected, get_account_percentile_mock
):
feature_toggle = FeatureToggle(
FeatureToggleLocalConfigProvider(os.path.join(my_path, "input", "feature_toggle_config.json")),
stage=None,
region=None,
account_id="123456789",
account_id=None,
)
get_account_percentile_mock.return_value = account_percentile
self.assertEqual(feature_toggle._is_feature_enabled_for_region_config("feature", region_config), expected)
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},
"us-east-1": {"enabled-%": 10},
"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}
Expand Down