| #!/usr/bin/env python3 |
| # Copyright 2024, The Android Open Source Project |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| """Rollout control for Atest features.""" |
| |
| import functools |
| import getpass |
| import hashlib |
| import importlib.resources |
| import logging |
| import os |
| from atest import atest_enum |
| from atest import atest_utils |
| from atest.metrics import metrics |
| |
| |
| @functools.cache |
| def _get_project_owners() -> list[str]: |
| """Returns the owners of the feature.""" |
| owners = [] |
| try: |
| with importlib.resources.as_file( |
| importlib.resources.files('atest').joinpath('OWNERS') |
| ) as version_file_path: |
| owners.extend(version_file_path.read_text(encoding='utf-8').splitlines()) |
| except (ModuleNotFoundError, FileNotFoundError) as e: |
| logging.error(e) |
| try: |
| with importlib.resources.as_file( |
| importlib.resources.files('atest').joinpath('OWNERS_ADTE_TEAM') |
| ) as version_file_path: |
| owners.extend(version_file_path.read_text(encoding='utf-8').splitlines()) |
| except (ModuleNotFoundError, FileNotFoundError) as e: |
| logging.error(e) |
| return [line.split('@')[0] for line in owners if '@google.com' in line] |
| |
| |
| class RolloutControlledFeature: |
| """Base class for Atest features under rollout control.""" |
| |
| def __init__( |
| self, |
| name: str, |
| rollout_percentage: float, |
| env_control_flag: str, |
| feature_id: int = None, |
| owners: list[str] | None = None, |
| print_message: str | None = None, |
| ): |
| """Initializes the object. |
| |
| Args: |
| name: The name of the feature. |
| rollout_percentage: The percentage of users to enable the feature for. |
| The value should be in [0, 100]. |
| env_control_flag: The environment variable name to override the feature |
| enablement. When set, 'true' or '1' means enable, other values means |
| disable. |
| feature_id: The ID of the feature that is controlled by rollout control |
| for metric collection purpose. Must be a positive integer. |
| owners: The owners of the feature. If not provided, the owners of the |
| feature will be read from OWNERS file. |
| print_message: The message to print to the console when the feature is |
| enabled for the user. |
| """ |
| if rollout_percentage < 0 or rollout_percentage > 100: |
| raise ValueError( |
| 'Rollout percentage must be in [0, 100]. Got %s instead.' |
| % rollout_percentage |
| ) |
| if feature_id is not None and feature_id <= 0: |
| raise ValueError( |
| 'Feature ID must be a positive integer. Got %s instead.' % feature_id |
| ) |
| if owners is None: |
| owners = _get_project_owners() |
| self._name = name |
| self._rollout_percentage = rollout_percentage |
| self._env_control_flag = env_control_flag |
| self._feature_id = feature_id |
| self._owners = owners |
| self._print_message = print_message |
| |
| def _check_env_control_flag(self) -> bool | None: |
| """Checks the environment variable to override the feature enablement. |
| |
| Returns: |
| True if the feature is enabled, False if disabled, None if not set. |
| """ |
| if self._env_control_flag not in os.environ: |
| return None |
| return os.environ[self._env_control_flag] in ('TRUE', 'True', 'true', '1') |
| |
| def _is_enabled_for_user(self, username: str | None) -> bool: |
| """Checks whether the feature is enabled for the user. |
| |
| Args: |
| username: The username to check the feature enablement for. If not |
| provided, the current user's username will be used. |
| |
| Returns: |
| True if the feature is enabled for the user, False otherwise. |
| """ |
| if self._rollout_percentage == 100: |
| return True |
| |
| if username is None: |
| username = getpass.getuser() |
| |
| if not username: |
| logging.debug( |
| 'Unable to determine the username. Disabling the feature %s.', |
| self._name, |
| ) |
| return False |
| |
| if username in self._owners: |
| return True |
| |
| hash_object = hashlib.sha256() |
| hash_object.update((username + ' ' + self._name).encode('utf-8')) |
| return int(hash_object.hexdigest(), 16) % 100 < self._rollout_percentage |
| |
| @functools.cache |
| def is_enabled(self, username: str | None = None) -> bool: |
| """Checks whether the current feature is enabled for the user. |
| |
| Args: |
| username: The username to check the feature enablement for. If not |
| provided, the current user's username will be used. |
| |
| Returns: |
| True if the feature is enabled for the user, False otherwise. |
| """ |
| override_flag_value = self._check_env_control_flag() |
| if override_flag_value is not None: |
| logging.debug( |
| 'Feature %s is %s by env variable %s.', |
| self._name, |
| 'enabled' if override_flag_value else 'disabled', |
| self._env_control_flag, |
| ) |
| if self._feature_id: |
| metrics.LocalDetectEvent( |
| detect_type=atest_enum.DetectType.ROLLOUT_CONTROLLED_FEATURE_ID_OVERRIDE, |
| result=self._feature_id |
| if override_flag_value |
| else -self._feature_id, |
| ) |
| return override_flag_value |
| |
| is_enabled = self._is_enabled_for_user(username) |
| |
| logging.debug( |
| 'Feature %s is %s for user %s.', |
| self._name, |
| 'enabled' if is_enabled else 'disabled', |
| username, |
| ) |
| |
| if self._feature_id: |
| metrics.LocalDetectEvent( |
| detect_type=atest_enum.DetectType.ROLLOUT_CONTROLLED_FEATURE_ID, |
| result=self._feature_id if is_enabled else -self._feature_id, |
| ) |
| |
| if is_enabled and self._print_message: |
| print(atest_utils.mark_magenta(self._print_message)) |
| |
| return is_enabled |
| |
| |
| deprecate_bazel_mode = RolloutControlledFeature( |
| name='Deprecate Bazel Mode', |
| rollout_percentage=60, |
| env_control_flag='DEPRECATE_BAZEL_MODE', |
| feature_id=1, |
| ) |
| |
| rolling_tf_subprocess_output = RolloutControlledFeature( |
| name='Rolling TradeFed subprocess output', |
| rollout_percentage=100, |
| env_control_flag='ROLLING_TF_SUBPROCESS_OUTPUT', |
| feature_id=2, |
| print_message=( |
| 'You are one of the first users receiving the "Rolling subprocess' |
| ' output" feature. If you are happy with it, please +1 on' |
| ' http://b/380460196.' |
| ), |
| ) |
| |
| tf_preparer_incremental_setup = RolloutControlledFeature( |
| name='TradeFed preparer incremental setup', |
| rollout_percentage=10, |
| env_control_flag='TF_PREPARER_INCREMENTAL_SETUP', |
| feature_id=3, |
| print_message=( |
| 'You are one of the first users selected to receive the "Incremental' |
| ' setup for TradeFed preparers" feature. If you are happy with it,' |
| ' please +1 on http://b/381900378. If you experienced any issues,' |
| ' please comment on the same bug.' |
| ), |
| ) |