1#!/usr/bin/env python3 2# Copyright 2024, The Android Open Source Project 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); 5# you may not use this file except in compliance with the License. 6# You may obtain a copy of the License at 7# 8# http://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15 16"""Rollout control for Atest features.""" 17 18import functools 19import getpass 20import hashlib 21import importlib.resources 22import logging 23import os 24from atest import atest_enum 25from atest import atest_utils 26from atest.metrics import metrics 27 28 29@functools.cache 30def _get_project_owners() -> list[str]: 31 """Returns the owners of the feature.""" 32 owners = [] 33 try: 34 with importlib.resources.as_file( 35 importlib.resources.files('atest').joinpath('OWNERS') 36 ) as version_file_path: 37 owners.extend(version_file_path.read_text(encoding='utf-8').splitlines()) 38 except (ModuleNotFoundError, FileNotFoundError) as e: 39 logging.error(e) 40 try: 41 with importlib.resources.as_file( 42 importlib.resources.files('atest').joinpath('OWNERS_ADTE_TEAM') 43 ) as version_file_path: 44 owners.extend(version_file_path.read_text(encoding='utf-8').splitlines()) 45 except (ModuleNotFoundError, FileNotFoundError) as e: 46 logging.error(e) 47 return [line.split('@')[0] for line in owners if '@google.com' in line] 48 49 50class RolloutControlledFeature: 51 """Base class for Atest features under rollout control.""" 52 53 def __init__( 54 self, 55 name: str, 56 rollout_percentage: float, 57 env_control_flag: str, 58 feature_id: int = None, 59 owners: list[str] | None = None, 60 print_message: str | None = None, 61 ): 62 """Initializes the object. 63 64 Args: 65 name: The name of the feature. 66 rollout_percentage: The percentage of users to enable the feature for. 67 The value should be in [0, 100]. 68 env_control_flag: The environment variable name to override the feature 69 enablement. When set, 'true' or '1' means enable, other values means 70 disable. 71 feature_id: The ID of the feature that is controlled by rollout control 72 for metric collection purpose. Must be a positive integer. 73 owners: The owners of the feature. If not provided, the owners of the 74 feature will be read from OWNERS file. 75 print_message: The message to print to the console when the feature is 76 enabled for the user. 77 """ 78 if rollout_percentage < 0 or rollout_percentage > 100: 79 raise ValueError( 80 'Rollout percentage must be in [0, 100]. Got %s instead.' 81 % rollout_percentage 82 ) 83 if feature_id is not None and feature_id <= 0: 84 raise ValueError( 85 'Feature ID must be a positive integer. Got %s instead.' % feature_id 86 ) 87 if owners is None: 88 owners = _get_project_owners() 89 self._name = name 90 self._rollout_percentage = rollout_percentage 91 self._env_control_flag = env_control_flag 92 self._feature_id = feature_id 93 self._owners = owners 94 self._print_message = print_message 95 96 def _check_env_control_flag(self) -> bool | None: 97 """Checks the environment variable to override the feature enablement. 98 99 Returns: 100 True if the feature is enabled, False if disabled, None if not set. 101 """ 102 if self._env_control_flag not in os.environ: 103 return None 104 return os.environ[self._env_control_flag] in ('TRUE', 'True', 'true', '1') 105 106 def _is_enabled_for_user(self, username: str | None) -> bool: 107 """Checks whether the feature is enabled for the user. 108 109 Args: 110 username: The username to check the feature enablement for. If not 111 provided, the current user's username will be used. 112 113 Returns: 114 True if the feature is enabled for the user, False otherwise. 115 """ 116 if self._rollout_percentage == 100: 117 return True 118 119 if username is None: 120 username = getpass.getuser() 121 122 if not username: 123 logging.debug( 124 'Unable to determine the username. Disabling the feature %s.', 125 self._name, 126 ) 127 return False 128 129 if username in self._owners: 130 return True 131 132 hash_object = hashlib.sha256() 133 hash_object.update((username + ' ' + self._name).encode('utf-8')) 134 return int(hash_object.hexdigest(), 16) % 100 < self._rollout_percentage 135 136 @functools.cache 137 def is_enabled(self, username: str | None = None) -> bool: 138 """Checks whether the current feature is enabled for the user. 139 140 Args: 141 username: The username to check the feature enablement for. If not 142 provided, the current user's username will be used. 143 144 Returns: 145 True if the feature is enabled for the user, False otherwise. 146 """ 147 override_flag_value = self._check_env_control_flag() 148 if override_flag_value is not None: 149 logging.debug( 150 'Feature %s is %s by env variable %s.', 151 self._name, 152 'enabled' if override_flag_value else 'disabled', 153 self._env_control_flag, 154 ) 155 if self._feature_id: 156 metrics.LocalDetectEvent( 157 detect_type=atest_enum.DetectType.ROLLOUT_CONTROLLED_FEATURE_ID_OVERRIDE, 158 result=self._feature_id 159 if override_flag_value 160 else -self._feature_id, 161 ) 162 return override_flag_value 163 164 is_enabled = self._is_enabled_for_user(username) 165 166 logging.debug( 167 'Feature %s is %s for user %s.', 168 self._name, 169 'enabled' if is_enabled else 'disabled', 170 username, 171 ) 172 173 if self._feature_id: 174 metrics.LocalDetectEvent( 175 detect_type=atest_enum.DetectType.ROLLOUT_CONTROLLED_FEATURE_ID, 176 result=self._feature_id if is_enabled else -self._feature_id, 177 ) 178 179 if is_enabled and self._print_message: 180 print(atest_utils.mark_magenta(self._print_message)) 181 182 return is_enabled 183 184 185deprecate_bazel_mode = RolloutControlledFeature( 186 name='Deprecate Bazel Mode', 187 rollout_percentage=60, 188 env_control_flag='DEPRECATE_BAZEL_MODE', 189 feature_id=1, 190) 191 192rolling_tf_subprocess_output = RolloutControlledFeature( 193 name='Rolling TradeFed subprocess output', 194 rollout_percentage=100, 195 env_control_flag='ROLLING_TF_SUBPROCESS_OUTPUT', 196 feature_id=2, 197 print_message=( 198 'You are one of the first users receiving the "Rolling subprocess' 199 ' output" feature. If you are happy with it, please +1 on' 200 ' http://b/380460196.' 201 ), 202) 203 204tf_preparer_incremental_setup = RolloutControlledFeature( 205 name='TradeFed preparer incremental setup', 206 rollout_percentage=0, 207 env_control_flag='TF_PREPARER_INCREMENTAL_SETUP', 208 feature_id=3, 209 print_message=( 210 'You are one of the first users selected to receive the "Incremental' 211 ' setup for TradeFed preparers" feature. If you are happy with it,' 212 ' please +1 on http://b/381900378. If you experienced any issues,' 213 ' please comment on the same bug.' 214 ), 215) 216