1import os
2import sys
3import json
4import boto3
5import logging
6import hashlib
7
8from botocore.config import Config
9from samtranslator.feature_toggle.dialup import (
10    DisabledDialup,
11    ToggleDialup,
12    SimpleAccountPercentileDialup,
13)
14
15my_path = os.path.dirname(os.path.abspath(__file__))
16sys.path.insert(0, my_path + "/..")
17
18LOG = logging.getLogger(__name__)
19
20
21class FeatureToggle:
22    """
23    FeatureToggle is the class which will provide methods to query and decide if a feature is enabled based on where
24    SAM is executing or not.
25    """
26
27    DIALUP_RESOLVER = {
28        "toggle": ToggleDialup,
29        "account-percentile": SimpleAccountPercentileDialup,
30    }
31
32    def __init__(self, config_provider, stage, account_id, region):
33        self.feature_config = config_provider.config
34        self.stage = stage
35        self.account_id = account_id
36        self.region = region
37
38    def _get_dialup(self, region_config, feature_name):
39        """
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
47        """
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)
55
56    def is_enabled(self, feature_name):
57        """
58        To check if feature is available
59
60        :param feature_name: name of feature
61        """
62        if feature_name not in self.feature_config:
63            LOG.warning("Feature '{}' not available in Feature Toggle Config.".format(feature_name))
64            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
75        stage_config = self.feature_config.get(feature_name, {}).get(stage, {})
76        if not stage_config:
77            LOG.info("Stage '{}' not enabled for Feature '{}'.".format(stage, feature_name))
78            return 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
90        LOG.info("Feature '{}' is enabled: '{}'".format(feature_name, is_enabled))
91        return is_enabled
92
93
94class FeatureToggleConfigProvider:
95    """Interface for all FeatureToggle config providers"""
96
97    def __init__(self):
98        pass
99
100    @property
101    def config(self):
102        raise NotImplementedError
103
104
105class FeatureToggleDefaultConfigProvider(FeatureToggleConfigProvider):
106    """Default config provider, always return False for every query."""
107
108    def __init__(self):
109        FeatureToggleConfigProvider.__init__(self)
110
111    @property
112    def config(self):
113        return {}
114
115
116class FeatureToggleLocalConfigProvider(FeatureToggleConfigProvider):
117    """Feature toggle config provider which uses a local file. This is to facilitate local testing."""
118
119    def __init__(self, local_config_path):
120        FeatureToggleConfigProvider.__init__(self)
121        with open(local_config_path, "r") as f:
122            config_json = f.read()
123        self.feature_toggle_config = json.loads(config_json)
124
125    @property
126    def config(self):
127        return self.feature_toggle_config
128
129
130class FeatureToggleAppConfigConfigProvider(FeatureToggleConfigProvider):
131    """Feature toggle config provider which loads config from AppConfig."""
132
133    def __init__(self, application_id, environment_id, configuration_profile_id):
134        FeatureToggleConfigProvider.__init__(self)
135        try:
136            LOG.info("Loading feature toggle config from AppConfig...")
137            # Lambda function has 120 seconds limit
138            # (5 + 5) * 2, 20 seconds maximum timeout duration
139            # In case of high latency from AppConfig, we can always fall back to use an empty config and continue transform
140            client_config = Config(connect_timeout=5, read_timeout=5, retries={"total_max_attempts": 2})
141            self.app_config_client = boto3.client("appconfig", config=client_config)
142            response = self.app_config_client.get_configuration(
143                Application=application_id,
144                Environment=environment_id,
145                Configuration=configuration_profile_id,
146                ClientId="FeatureToggleAppConfigConfigProvider",
147            )
148            binary_config_string = response["Content"].read()
149            self.feature_toggle_config = json.loads(binary_config_string.decode("utf-8"))
150            LOG.info("Finished loading feature toggle config from AppConfig.")
151        except Exception as ex:
152            LOG.error("Failed to load config from AppConfig: {}. Using empty config.".format(ex))
153            # There is chance that AppConfig is not available in a particular region.
154            self.feature_toggle_config = json.loads("{}")
155
156    @property
157    def config(self):
158        return self.feature_toggle_config
159