Skip to content

Commit 240f820

Browse files
hawflauTarun K. Mall
authored andcommitted
Percentage-based Enablement for Feature Toggle (aws#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
1 parent d6d4f52 commit 240f820

File tree

6 files changed

+284
-83
lines changed

6 files changed

+284
-83
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import hashlib
2+
3+
4+
class BaseDialup(object):
5+
"""BaseDialup class to provide an interface for all dialup classes"""
6+
7+
def __init__(self, region_config, **kwargs):
8+
self.region_config = region_config
9+
10+
def is_enabled(self):
11+
"""
12+
Returns a bool on whether this dialup is enabled or not
13+
"""
14+
raise NotImplementedError
15+
16+
def __str__(self):
17+
return self.__class__.__name__
18+
19+
20+
class DisabledDialup(BaseDialup):
21+
"""
22+
A dialup that is never enabled
23+
"""
24+
25+
def __init__(self, region_config, **kwargs):
26+
super(DisabledDialup, self).__init__(region_config)
27+
28+
def is_enabled(self):
29+
return False
30+
31+
32+
class ToggleDialup(BaseDialup):
33+
"""
34+
A simple toggle Dialup
35+
Example of region_config: { "type": "toggle", "enabled": True }
36+
"""
37+
38+
def __init__(self, region_config, **kwargs):
39+
super(ToggleDialup, self).__init__(region_config)
40+
self.region_config = region_config
41+
42+
def is_enabled(self):
43+
return self.region_config.get("enabled", False)
44+
45+
46+
class SimpleAccountPercentileDialup(BaseDialup):
47+
"""
48+
Simple account percentile dialup, enabling X% of
49+
Example of region_config: { "type": "account-percentile", "enabled-%": 20 }
50+
"""
51+
52+
def __init__(self, region_config, account_id, feature_name, **kwargs):
53+
super(SimpleAccountPercentileDialup, self).__init__(region_config)
54+
self.account_id = account_id
55+
self.feature_name = feature_name
56+
57+
def _get_account_percentile(self):
58+
"""
59+
Get account percentile based on sha256 hash of account ID and feature_name
60+
61+
:returns: integer n, where 0 <= n < 100
62+
"""
63+
m = hashlib.sha256()
64+
m.update(self.account_id.encode())
65+
m.update(self.feature_name.encode())
66+
return int(m.hexdigest(), 16) % 100
67+
68+
def is_enabled(self):
69+
"""
70+
Enable when account_percentile falls within target_percentile
71+
Meaning only (target_percentile)% of accounts will be enabled
72+
"""
73+
target_percentile = self.region_config.get("enabled-%", 0)
74+
return self._get_account_percentile() < target_percentile

samtranslator/feature_toggle/feature_toggle.py

Lines changed: 54 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@
33
import json
44
import boto3
55
import logging
6+
import hashlib
67

78
from botocore.config import Config
9+
from samtranslator.feature_toggle.dialup import (
10+
DisabledDialup,
11+
ToggleDialup,
12+
SimpleAccountPercentileDialup,
13+
)
814

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

21-
def __init__(self, config_provider):
27+
DIALUP_RESOLVER = {
28+
"toggle": ToggleDialup,
29+
"account-percentile": SimpleAccountPercentileDialup,
30+
}
31+
32+
def __init__(self, config_provider, stage, account_id, region):
2233
self.feature_config = config_provider.config
34+
self.stage = stage
35+
self.account_id = account_id
36+
self.region = region
2337

24-
def is_enabled_for_stage_in_region(self, feature_name, stage, region="default"):
38+
def _get_dialup(self, region_config, feature_name):
2539
"""
26-
To check if feature is available for a particular stage or not.
27-
:param feature_name: name of feature
28-
:param stage: stage where SAM is running
29-
:param region: region in which SAM is running
30-
:return:
40+
get the right dialup instance
41+
if no dialup type is provided or the specified dialup is not supported,
42+
an instance of DisabledDialup will be returned
43+
44+
:param region_config: region config
45+
:param feature_name: feature_name
46+
:return: an instance of
3147
"""
32-
if feature_name not in self.feature_config:
33-
LOG.warning("Feature '{}' not available in Feature Toggle Config.".format(feature_name))
34-
return False
35-
stage_config = self.feature_config.get(feature_name, {}).get(stage, {})
36-
if not stage_config:
37-
LOG.info("Stage '{}' not enabled for Feature '{}'.".format(stage, feature_name))
38-
return False
39-
region_config = stage_config.get(region, {}) if region in stage_config else stage_config.get("default", {})
40-
is_enabled = region_config.get("enabled", False)
41-
LOG.info("Feature '{}' is enabled: '{}'".format(feature_name, is_enabled))
42-
return is_enabled
48+
dialup_type = region_config.get("type")
49+
if dialup_type in FeatureToggle.DIALUP_RESOLVER:
50+
return FeatureToggle.DIALUP_RESOLVER[dialup_type](
51+
region_config, account_id=self.account_id, feature_name=feature_name
52+
)
53+
LOG.warning("Dialup type '{}' is None or is not supported.".format(dialup_type))
54+
return DisabledDialup(region_config)
4355

44-
def is_enabled_for_account_in_region(self, feature_name, stage, account_id, region="default"):
56+
def is_enabled(self, feature_name):
4557
"""
46-
To check if feature is available for a particular account or not.
58+
To check if feature is available
59+
4760
:param feature_name: name of feature
48-
:param stage: stage where SAM is running
49-
:param account_id: account_id who is executing SAM template
50-
:param region: region in which SAM is running
51-
:return:
5261
"""
5362
if feature_name not in self.feature_config:
5463
LOG.warning("Feature '{}' not available in Feature Toggle Config.".format(feature_name))
5564
return False
65+
66+
stage = self.stage
67+
region = self.region
68+
account_id = self.account_id
69+
if not stage or not region or not account_id:
70+
LOG.warning(
71+
"One or more of stage, region and account_id is not set. Feature '{}' not enabled.".format(feature_name)
72+
)
73+
return False
74+
5675
stage_config = self.feature_config.get(feature_name, {}).get(stage, {})
5776
if not stage_config:
5877
LOG.info("Stage '{}' not enabled for Feature '{}'.".format(stage, feature_name))
5978
return False
60-
account_config = stage_config.get(account_id) if account_id in stage_config else stage_config.get("default", {})
61-
region_config = (
62-
account_config.get(region, {}) if region in account_config else account_config.get("default", {})
63-
)
64-
is_enabled = region_config.get("enabled", False)
79+
80+
if account_id in stage_config:
81+
account_config = stage_config[account_id]
82+
region_config = account_config[region] if region in account_config else account_config.get("default", {})
83+
else:
84+
region_config = stage_config[region] if region in stage_config else stage_config.get("default", {})
85+
86+
dialup = self._get_dialup(region_config, feature_name=feature_name)
87+
LOG.info("Using Dialip {}".format(dialup))
88+
is_enabled = dialup.is_enabled()
89+
6590
LOG.info("Feature '{}' is enabled: '{}'".format(feature_name, is_enabled))
6691
return is_enabled
6792

samtranslator/translator/translator.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,11 @@ def translate(self, sam_template, parameter_values, feature_toggle=None):
9292
:returns: a copy of the template with SAM resources replaced with the corresponding CloudFormation, which may \
9393
be dumped into a valid CloudFormation JSON or YAML template
9494
"""
95-
self.feature_toggle = feature_toggle if feature_toggle else FeatureToggle(FeatureToggleDefaultConfigProvider())
95+
self.feature_toggle = (
96+
feature_toggle
97+
if feature_toggle
98+
else FeatureToggle(FeatureToggleDefaultConfigProvider(), stage=None, account_id=None, region=None)
99+
)
96100
self.function_names = dict()
97101
self.redeploy_restapi_parameters = dict()
98102
sam_parameter_values = SamParameterValues(parameter_values)

tests/feature_toggle/input/feature_toggle_config.json

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,20 @@
22
"__note__": "This is a dummy config for local testing. Any change here need to be migrated to SAM service.",
33
"feature-1": {
44
"beta": {
5-
"us-west-2": {"enabled": true},
6-
"default": {"enabled": false},
7-
"123456789123": {"us-west-2": {"enabled": true}, "default": {"enabled": false}}
5+
"us-west-2": {"type": "toggle", "enabled": true},
6+
"us-east-1": {"type": "account-percentile", "enabled-%": 10},
7+
"default": {"type": "toggle", "enabled": false},
8+
"123456789123": {
9+
"us-west-2": {"type": "toggle", "enabled": true},
10+
"default": {"type": "toggle", "enabled": false}
11+
}
812
},
913
"gamma": {
10-
"default": {"enabled": false},
11-
"123456789123": {"us-east-1": {"enabled": false}, "default": {"enabled": false}}
14+
"default": {"type": "toggle", "enabled": false},
15+
"123456789123": {
16+
"us-east-1": {"type": "toggle", "enabled": false},
17+
"default": {"type": "toggle", "enabled": false}
18+
}
1219
},
1320
"prod": {"default": {"enabled": false}}
1421
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from unittest import TestCase
2+
3+
from parameterized import parameterized, param
4+
from samtranslator.feature_toggle.dialup import *
5+
6+
7+
class TestBaseDialup(TestCase):
8+
def test___str__(self):
9+
region_config = {}
10+
dialup = BaseDialup(region_config)
11+
self.assertEqual(str(dialup), "BaseDialup")
12+
13+
14+
class TestDisabledDialup(TestCase):
15+
def test_is_enabled(self):
16+
region_config = {}
17+
dialup = DisabledDialup(region_config)
18+
self.assertFalse(dialup.is_enabled())
19+
20+
21+
class TestToggleDialUp(TestCase):
22+
@parameterized.expand(
23+
[
24+
param({"type": "toggle", "enabled": True}, True),
25+
param({"type": "toggle", "enabled": False}, False),
26+
param({"type": "toggle"}, False), # missing "enabled" key
27+
]
28+
)
29+
def test_is_enabled(self, region_config, expected):
30+
dialup = ToggleDialup(region_config)
31+
self.assertEqual(dialup.is_enabled(), expected)
32+
33+
34+
class TestSimpleAccountPercentileDialup(TestCase):
35+
@parameterized.expand(
36+
[
37+
param({"type": "account-percentile", "enabled-%": 10}, "feature-1", "123456789100", True),
38+
param({"type": "account-percentile", "enabled-%": 10}, "feautre-1", "123456789123", False),
39+
param({"type": "account-percentile", "enabled": True}, "feature-1", "123456789100", False),
40+
]
41+
)
42+
def test_is_enabled(self, region_config, feature_name, account_id, expected):
43+
dialup = SimpleAccountPercentileDialup(
44+
region_config=region_config,
45+
account_id=account_id,
46+
feature_name=feature_name,
47+
)
48+
self.assertEqual(dialup.is_enabled(), expected)
49+
50+
@parameterized.expand(
51+
[
52+
param("feature-1", "123456789123"),
53+
param("feature-2", "000000000000"),
54+
param("feature-3", "432187654321"),
55+
param("feature-4", "111222333444"),
56+
]
57+
)
58+
def test__get_account_percentile(self, account_id, feature_name):
59+
region_config = {"type": "account-percentile", "enabled-%": 10}
60+
dialup = SimpleAccountPercentileDialup(
61+
region_config=region_config,
62+
account_id=account_id,
63+
feature_name=feature_name,
64+
)
65+
self.assertTrue(0 <= dialup._get_account_percentile() < 100)

0 commit comments

Comments
 (0)