1# -------------------------------------------------------------------------------------------- 2# Copyright (c) Microsoft Corporation. All rights reserved. 3# Licensed under the MIT License. See License.txt in the project root for license information. 4# -------------------------------------------------------------------------------------------- 5 6# pylint: disable=line-too-long, too-many-statements, too-many-locals, too-many-branches 7 8import json 9import time 10import re 11import copy 12 13from knack.log import get_logger 14from knack.util import CLIError 15from azure.cli.core.azclierror import RequiredArgumentMissingError 16 17from azure.appconfiguration import (ConfigurationSetting, 18 ResourceReadOnlyError) 19from azure.core import MatchConditions 20from azure.cli.core.util import user_confirmation 21from azure.core.exceptions import (HttpResponseError, 22 ResourceNotFoundError, 23 ResourceModifiedError) 24 25from ._constants import (FeatureFlagConstants, SearchFilterOptions, StatusCodes) 26from ._models import (KeyValue, 27 convert_configurationsetting_to_keyvalue, 28 convert_keyvalue_to_configurationsetting) 29from ._utils import (get_appconfig_data_client, 30 prep_label_filter_for_url_encoding) 31from ._featuremodels import (map_keyvalue_to_featureflag, 32 map_keyvalue_to_featureflagvalue, 33 FeatureFilter) 34 35 36logger = get_logger(__name__) 37 38# Feature commands # 39 40 41def set_feature(cmd, 42 feature=None, 43 key=None, 44 name=None, 45 label=None, 46 description=None, 47 yes=False, 48 connection_string=None, 49 auth_mode="key", 50 endpoint=None): 51 if key is None and feature is None: 52 raise RequiredArgumentMissingError("Please provide either `--key` or `--feature` value.") 53 54 key = FeatureFlagConstants.FEATURE_FLAG_PREFIX + feature if key is None else key 55 feature = key[len(FeatureFlagConstants.FEATURE_FLAG_PREFIX):] if feature is None else feature 56 57 # when creating a new Feature flag, these defaults will be used 58 tags = {} 59 default_conditions = {'client_filters': []} 60 61 default_value = { 62 "id": feature, 63 "description": "" if description is None else description, 64 "enabled": False, 65 "conditions": default_conditions 66 } 67 68 azconfig_client = get_appconfig_data_client(cmd, name, connection_string, auth_mode, endpoint) 69 70 retry_times = 3 71 retry_interval = 1 72 73 label = label if label and label != SearchFilterOptions.EMPTY_LABEL else None 74 for i in range(0, retry_times): 75 retrieved_kv = None 76 set_kv = None 77 set_configsetting = None 78 new_kv = None 79 80 try: 81 retrieved_kv = azconfig_client.get_configuration_setting(key=key, label=label) 82 except ResourceNotFoundError: 83 logger.debug("Feature flag '%s' with label '%s' not found. A new feature flag will be created.", feature, label) 84 except HttpResponseError as exception: 85 raise CLIError("Failed to retrieve feature flags from config store. " + str(exception)) 86 87 try: 88 # if kv exists and only content-type is wrong, we can force correct it by updating the kv 89 if retrieved_kv is None: 90 set_kv = KeyValue(key=key, 91 value=json.dumps(default_value, ensure_ascii=False), 92 label=label, 93 tags=tags, 94 content_type=FeatureFlagConstants.FEATURE_FLAG_CONTENT_TYPE) 95 else: 96 if retrieved_kv.content_type != FeatureFlagConstants.FEATURE_FLAG_CONTENT_TYPE: 97 logger.warning( 98 "This feature contains invalid content-type. The feature flag will be overwritten.") 99 # we make sure that value retrieved is a valid json and only has the fields supported by backend. 100 # if it's invalid, we catch appropriate exception that contains 101 # detailed message 102 feature_flag_value = map_keyvalue_to_featureflagvalue(retrieved_kv) 103 104 # User can only update description if the key already exists 105 if description is not None: 106 feature_flag_value.description = description 107 108 set_kv = KeyValue(key=key, 109 label=label, 110 value=json.dumps(feature_flag_value, default=lambda o: o.__dict__, ensure_ascii=False), 111 content_type=FeatureFlagConstants.FEATURE_FLAG_CONTENT_TYPE, 112 tags=retrieved_kv.tags if tags is None else tags, 113 etag=retrieved_kv.etag, 114 last_modified=retrieved_kv.last_modified) 115 116 # Convert KeyValue object to required FeatureFlag format for 117 # display 118 feature_flag = map_keyvalue_to_featureflag(set_kv, show_conditions=True) 119 entry = json.dumps(feature_flag, default=lambda o: o.__dict__, indent=2, sort_keys=True, ensure_ascii=False) 120 121 except Exception as exception: 122 # Exceptions for ValueError and AttributeError already have customized message 123 # No need to catch specific exception here and customize 124 raise CLIError(str(exception)) 125 126 confirmation_message = "Are you sure you want to set the feature flag: \n" + entry + "\n" 127 user_confirmation(confirmation_message, yes) 128 set_configsetting = convert_keyvalue_to_configurationsetting(set_kv) 129 130 try: 131 if set_configsetting.etag is None: 132 new_kv = azconfig_client.add_configuration_setting(set_configsetting) 133 else: 134 new_kv = azconfig_client.set_configuration_setting(set_configsetting, match_condition=MatchConditions.IfNotModified) 135 return map_keyvalue_to_featureflag(convert_configurationsetting_to_keyvalue(new_kv)) 136 137 except ResourceReadOnlyError: 138 raise CLIError("Failed to update read only feature flag. Unlock the feature flag before updating it.") 139 except HttpResponseError as exception: 140 if exception.status_code == StatusCodes.PRECONDITION_FAILED: 141 logger.debug('Retrying setting %s times with exception: concurrent setting operations', i + 1) 142 time.sleep(retry_interval) 143 else: 144 raise CLIError(str(exception)) 145 except Exception as exception: 146 raise CLIError(str(exception)) 147 raise CLIError("Failed to set the feature flag '{}' due to a conflicting operation.".format(feature)) 148 149 150def delete_feature(cmd, 151 feature=None, 152 key=None, 153 name=None, 154 label=None, 155 yes=False, 156 connection_string=None, 157 auth_mode="key", 158 endpoint=None): 159 if key is None and feature is None: 160 raise RequiredArgumentMissingError("Please provide either `--key` or `--feature` value.") 161 if key and feature: 162 logger.warning("Since both `--key` and `--feature` are provided, `--feature` argument will be ignored.") 163 164 if key is not None: 165 key_filter = key 166 elif feature is not None: 167 key_filter = FeatureFlagConstants.FEATURE_FLAG_PREFIX + feature 168 169 azconfig_client = get_appconfig_data_client(cmd, name, connection_string, auth_mode, endpoint) 170 171 retrieved_keyvalues = __list_all_keyvalues(azconfig_client, 172 key_filter=key_filter, 173 label=SearchFilterOptions.EMPTY_LABEL if label is None else label) 174 175 confirmation_message = "Found '{}' feature flags matching the specified feature and label. Are you sure you want to delete these feature flags?".format(len(retrieved_keyvalues)) 176 user_confirmation(confirmation_message, yes) 177 178 deleted_kvs = [] 179 exception_messages = [] 180 for entry in retrieved_keyvalues: 181 feature_name = entry.key[len(FeatureFlagConstants.FEATURE_FLAG_PREFIX):] 182 try: 183 deleted_kv = azconfig_client.delete_configuration_setting(key=entry.key, 184 label=entry.label, 185 etag=entry.etag, 186 match_condition=MatchConditions.IfNotModified) 187 deleted_kvs.append(convert_configurationsetting_to_keyvalue(deleted_kv)) 188 except ResourceReadOnlyError: 189 exception = "Failed to delete read-only feature '{}' with label '{}'. Unlock the feature flag before deleting it.".format(feature_name, entry.label) 190 exception_messages.append(exception) 191 except ResourceModifiedError: 192 exception = "Failed to delete feature '{}' with label '{}' due to a conflicting operation.".format(feature_name, entry.label) 193 exception_messages.append(exception) 194 except HttpResponseError as ex: 195 exception_messages.append(str(ex)) 196 raise CLIError('Delete operation failed. The following error(s) occurred:\n' + json.dumps(exception_messages, indent=2, ensure_ascii=False)) 197 198 # Log errors if partially succeeded 199 if exception_messages: 200 if deleted_kvs: 201 logger.error('Delete operation partially failed. The following error(s) occurred:\n%s\n', 202 json.dumps(exception_messages, indent=2, ensure_ascii=False)) 203 else: 204 raise CLIError('Delete operation failed. \n' + json.dumps(exception_messages, indent=2, ensure_ascii=False)) 205 206 # Convert result list of KeyValue to list of FeatureFlag 207 deleted_ff = [] 208 for success_kv in deleted_kvs: 209 success_ff = map_keyvalue_to_featureflag(success_kv, show_conditions=False) 210 deleted_ff.append(success_ff) 211 212 return deleted_ff 213 214 215def show_feature(cmd, 216 feature=None, 217 key=None, 218 name=None, 219 label=None, 220 fields=None, 221 connection_string=None, 222 auth_mode="key", 223 endpoint=None): 224 if key is None and feature is None: 225 raise RequiredArgumentMissingError("Please provide either `--key` or `--feature` value.") 226 if key and feature: 227 logger.warning("Since both `--key` and `--feature` are provided, `--feature` argument will be ignored.") 228 229 key = FeatureFlagConstants.FEATURE_FLAG_PREFIX + feature if key is None else key 230 # Get feature name from key for logging. If users have provided a different feature name, we ignore it anyway. 231 feature = key[len(FeatureFlagConstants.FEATURE_FLAG_PREFIX):] 232 233 azconfig_client = get_appconfig_data_client(cmd, name, connection_string, auth_mode, endpoint) 234 235 try: 236 config_setting = azconfig_client.get_configuration_setting(key=key, label=label) 237 if config_setting is None or config_setting.content_type != FeatureFlagConstants.FEATURE_FLAG_CONTENT_TYPE: 238 raise CLIError("The feature flag does not exist.") 239 240 retrieved_kv = convert_configurationsetting_to_keyvalue(config_setting) 241 feature_flag = map_keyvalue_to_featureflag(keyvalue=retrieved_kv, show_conditions=True) 242 243 # If user has specified fields, we still get all the fields and then 244 # filter what we need from the response. 245 if fields: 246 partial_featureflag = {} 247 for field in fields: 248 # feature_flag is guaranteed to have all the fields because 249 # we validate this in map_keyvalue_to_featureflag() 250 # So this line will never throw AttributeError 251 partial_featureflag[field.name.lower()] = getattr(feature_flag, field.name.lower()) 252 return partial_featureflag 253 return feature_flag 254 except ResourceNotFoundError: 255 raise CLIError("Feature '{}' with label '{}' does not exist.".format(feature, label)) 256 except HttpResponseError as exception: 257 raise CLIError(str(exception)) 258 259 260def list_feature(cmd, 261 feature=None, 262 key=None, 263 name=None, 264 label=None, 265 fields=None, 266 connection_string=None, 267 top=None, 268 all_=False, 269 auth_mode="key", 270 endpoint=None): 271 if key and feature: 272 logger.warning("Since both `--key` and `--feature` are provided, `--feature` argument will be ignored.") 273 274 if key is not None: 275 key_filter = key 276 elif feature is not None: 277 key_filter = FeatureFlagConstants.FEATURE_FLAG_PREFIX + feature 278 else: 279 key_filter = FeatureFlagConstants.FEATURE_FLAG_PREFIX + SearchFilterOptions.ANY_KEY 280 281 azconfig_client = get_appconfig_data_client(cmd, name, connection_string, auth_mode, endpoint) 282 try: 283 retrieved_keyvalues = __list_all_keyvalues(azconfig_client, 284 key_filter=key_filter, 285 label=label if label else SearchFilterOptions.ANY_LABEL) 286 retrieved_featureflags = [] 287 for kv in retrieved_keyvalues: 288 retrieved_featureflags.append( 289 map_keyvalue_to_featureflag( 290 keyvalue=kv, show_conditions=True)) 291 filtered_featureflags = [] 292 count = 0 293 294 if all_: 295 top = len(retrieved_featureflags) 296 elif top is None: 297 top = 100 298 299 for featureflag in retrieved_featureflags: 300 if fields: 301 partial_featureflags = {} 302 for field in fields: 303 # featureflag is guaranteed to have all the fields because 304 # we validate this in map_keyvalue_to_featureflag() 305 # So this line will never throw AttributeError 306 partial_featureflags[field.name.lower()] = getattr( 307 featureflag, field.name.lower()) 308 filtered_featureflags.append(partial_featureflags) 309 else: 310 filtered_featureflags.append(featureflag) 311 count += 1 312 if count >= top: 313 break 314 return filtered_featureflags 315 316 except Exception as exception: 317 raise CLIError(str(exception)) 318 319 320def lock_feature(cmd, 321 feature=None, 322 key=None, 323 name=None, 324 label=None, 325 connection_string=None, 326 yes=False, 327 auth_mode="key", 328 endpoint=None): 329 if key is None and feature is None: 330 raise RequiredArgumentMissingError("Please provide either `--key` or `--feature` value.") 331 if key and feature: 332 logger.warning("Since both `--key` and `--feature` are provided, `--feature` argument will be ignored.") 333 334 key = FeatureFlagConstants.FEATURE_FLAG_PREFIX + feature if key is None else key 335 # Get feature name from key for logging. If users have provided a different feature name, we ignore it anyway. 336 feature = key[len(FeatureFlagConstants.FEATURE_FLAG_PREFIX):] 337 338 azconfig_client = get_appconfig_data_client(cmd, name, connection_string, auth_mode, endpoint) 339 340 retry_times = 3 341 retry_interval = 1 342 for i in range(0, retry_times): 343 try: 344 retrieved_kv = azconfig_client.get_configuration_setting(key=key, label=label) 345 except ResourceNotFoundError: 346 raise CLIError("Feature '{}' with label '{}' does not exist.".format(feature, label)) 347 except HttpResponseError as exception: 348 raise CLIError("Failed to retrieve feature flags from config store. " + str(exception)) 349 350 if retrieved_kv is None or retrieved_kv.content_type != FeatureFlagConstants.FEATURE_FLAG_CONTENT_TYPE: 351 raise CLIError("The feature '{}' you are trying to lock does not exist.".format(feature)) 352 353 confirmation_message = "Are you sure you want to lock the feature '{}' with label '{}'".format(feature, label) 354 user_confirmation(confirmation_message, yes) 355 356 try: 357 new_kv = azconfig_client.set_read_only(retrieved_kv, match_condition=MatchConditions.IfNotModified) 358 return map_keyvalue_to_featureflag(convert_configurationsetting_to_keyvalue(new_kv), 359 show_conditions=False) 360 except HttpResponseError as exception: 361 if exception.status_code == StatusCodes.PRECONDITION_FAILED: 362 logger.debug('Retrying lock operation %s times with exception: concurrent setting operations', i + 1) 363 time.sleep(retry_interval) 364 else: 365 raise CLIError(str(exception)) 366 except Exception as exception: 367 raise CLIError(str(exception)) 368 raise CLIError("Failed to lock the feature '{}' with label '{}' due to a conflicting operation.".format(feature, label)) 369 370 371def unlock_feature(cmd, 372 feature=None, 373 key=None, 374 name=None, 375 label=None, 376 connection_string=None, 377 yes=False, 378 auth_mode="key", 379 endpoint=None): 380 if key is None and feature is None: 381 raise RequiredArgumentMissingError("Please provide either `--key` or `--feature` value.") 382 if key and feature: 383 logger.warning("Since both `--key` and `--feature` are provided, `--feature` argument will be ignored.") 384 385 key = FeatureFlagConstants.FEATURE_FLAG_PREFIX + feature if key is None else key 386 # Get feature name from key for logging. If users have provided a different feature name, we ignore it anyway. 387 feature = key[len(FeatureFlagConstants.FEATURE_FLAG_PREFIX):] 388 389 azconfig_client = get_appconfig_data_client(cmd, name, connection_string, auth_mode, endpoint) 390 391 retry_times = 3 392 retry_interval = 1 393 for i in range(0, retry_times): 394 try: 395 retrieved_kv = azconfig_client.get_configuration_setting(key=key, label=label) 396 except ResourceNotFoundError: 397 raise CLIError("Feature '{}' with label '{}' does not exist.".format(feature, label)) 398 except HttpResponseError as exception: 399 raise CLIError("Failed to retrieve feature flags from config store. " + str(exception)) 400 401 if retrieved_kv is None or retrieved_kv.content_type != FeatureFlagConstants.FEATURE_FLAG_CONTENT_TYPE: 402 raise CLIError("The feature '{}' you are trying to unlock does not exist.".format(feature)) 403 404 confirmation_message = "Are you sure you want to unlock the feature '{}' with label '{}'".format(feature, label) 405 user_confirmation(confirmation_message, yes) 406 407 try: 408 new_kv = azconfig_client.set_read_only(retrieved_kv, read_only=False, match_condition=MatchConditions.IfNotModified) 409 return map_keyvalue_to_featureflag(convert_configurationsetting_to_keyvalue(new_kv), 410 show_conditions=False) 411 except HttpResponseError as exception: 412 if exception.status_code == StatusCodes.PRECONDITION_FAILED: 413 logger.debug('Retrying unlock operation %s times with exception: concurrent setting operations', i + 1) 414 time.sleep(retry_interval) 415 else: 416 raise CLIError(str(exception)) 417 except Exception as exception: 418 raise CLIError(str(exception)) 419 raise CLIError("Failed to unlock the feature '{}' with label '{}' due to a conflicting operation.".format(feature, label)) 420 421 422def enable_feature(cmd, 423 feature=None, 424 key=None, 425 name=None, 426 label=None, 427 connection_string=None, 428 yes=False, 429 auth_mode="key", 430 endpoint=None): 431 if key is None and feature is None: 432 raise RequiredArgumentMissingError("Please provide either `--key` or `--feature` value.") 433 if key and feature: 434 logger.warning("Since both `--key` and `--feature` are provided, `--feature` argument will be ignored.") 435 436 key = FeatureFlagConstants.FEATURE_FLAG_PREFIX + feature if key is None else key 437 # Get feature name from key for logging. If users have provided a different feature name, we ignore it anyway. 438 feature = key[len(FeatureFlagConstants.FEATURE_FLAG_PREFIX):] 439 440 azconfig_client = get_appconfig_data_client(cmd, name, connection_string, auth_mode, endpoint) 441 442 retry_times = 3 443 retry_interval = 1 444 for i in range(0, retry_times): 445 try: 446 retrieved_kv = azconfig_client.get_configuration_setting(key=key, label=label) 447 except ResourceNotFoundError: 448 raise CLIError("Feature flag '{}' with label '{}' not found.".format(feature, label)) 449 except HttpResponseError as exception: 450 raise CLIError("Failed to retrieve feature flags from config store. " + str(exception)) 451 452 try: 453 if retrieved_kv is None or retrieved_kv.content_type != FeatureFlagConstants.FEATURE_FLAG_CONTENT_TYPE: 454 raise CLIError("The feature flag {} does not exist.".format(feature)) 455 456 # we make sure that value retrieved is a valid json and only has the fields supported by backend. 457 # if it's invalid, we catch appropriate exception that contains 458 # detailed message 459 feature_flag_value = map_keyvalue_to_featureflagvalue(retrieved_kv) 460 461 feature_flag_value.enabled = True 462 confirmation_message = "Are you sure you want to enable this feature '{}'?".format(feature) 463 user_confirmation(confirmation_message, yes) 464 465 updated_key_value = __update_existing_key_value(azconfig_client=azconfig_client, 466 retrieved_kv=retrieved_kv, 467 updated_value=json.dumps(feature_flag_value, 468 default=lambda o: o.__dict__, 469 ensure_ascii=False)) 470 471 return map_keyvalue_to_featureflag(keyvalue=updated_key_value, show_conditions=False) 472 473 except HttpResponseError as exception: 474 if exception.status_code == StatusCodes.PRECONDITION_FAILED: 475 logger.debug('Retrying feature enable operation %s times with exception: concurrent setting operations', i + 1) 476 time.sleep(retry_interval) 477 else: 478 raise CLIError(str(exception)) 479 except Exception as exception: 480 raise CLIError(str(exception)) 481 raise CLIError("Failed to enable the feature flag '{}' due to a conflicting operation.".format(feature)) 482 483 484def disable_feature(cmd, 485 feature=None, 486 key=None, 487 name=None, 488 label=None, 489 connection_string=None, 490 yes=False, 491 auth_mode="key", 492 endpoint=None): 493 if key is None and feature is None: 494 raise RequiredArgumentMissingError("Please provide either `--key` or `--feature` value.") 495 if key and feature: 496 logger.warning("Since both `--key` and `--feature` are provided, `--feature` argument will be ignored.") 497 498 key = FeatureFlagConstants.FEATURE_FLAG_PREFIX + feature if key is None else key 499 # Get feature name from key for logging. If users have provided a different feature name, we ignore it anyway. 500 feature = key[len(FeatureFlagConstants.FEATURE_FLAG_PREFIX):] 501 502 azconfig_client = get_appconfig_data_client(cmd, name, connection_string, auth_mode, endpoint) 503 504 retry_times = 3 505 retry_interval = 1 506 for i in range(0, retry_times): 507 try: 508 retrieved_kv = azconfig_client.get_configuration_setting(key=key, label=label) 509 except ResourceNotFoundError: 510 raise CLIError("Feature flag '{}' with label '{}' not found.".format(feature, label)) 511 except HttpResponseError as exception: 512 raise CLIError("Failed to retrieve feature flags from config store. " + str(exception)) 513 514 try: 515 if retrieved_kv is None or retrieved_kv.content_type != FeatureFlagConstants.FEATURE_FLAG_CONTENT_TYPE: 516 raise CLIError("The feature flag {} does not exist.".format(feature)) 517 518 # we make sure that value retrieved is a valid json and only has the fields supported by backend. 519 # if it's invalid, we catch appropriate exception that contains 520 # detailed message 521 feature_flag_value = map_keyvalue_to_featureflagvalue(retrieved_kv) 522 523 feature_flag_value.enabled = False 524 confirmation_message = "Are you sure you want to disable this feature '{}'?".format(feature) 525 user_confirmation(confirmation_message, yes) 526 527 updated_key_value = __update_existing_key_value(azconfig_client=azconfig_client, 528 retrieved_kv=retrieved_kv, 529 updated_value=json.dumps(feature_flag_value, 530 default=lambda o: o.__dict__, 531 ensure_ascii=False)) 532 533 return map_keyvalue_to_featureflag(keyvalue=updated_key_value, show_conditions=False) 534 535 except HttpResponseError as exception: 536 if exception.status_code == StatusCodes.PRECONDITION_FAILED: 537 logger.debug('Retrying feature disable operation %s times with exception: concurrent setting operations', i + 1) 538 time.sleep(retry_interval) 539 else: 540 raise CLIError(str(exception)) 541 except Exception as exception: 542 raise CLIError(str(exception)) 543 raise CLIError("Failed to disable the feature flag '{}' due to a conflicting operation.".format(feature)) 544 545 546# Feature Filter commands # 547 548 549def add_filter(cmd, 550 filter_name, 551 feature=None, 552 key=None, 553 name=None, 554 label=None, 555 filter_parameters=None, 556 yes=False, 557 index=None, 558 connection_string=None, 559 auth_mode="key", 560 endpoint=None): 561 if key is None and feature is None: 562 raise RequiredArgumentMissingError("Please provide either `--key` or `--feature` value.") 563 if key and feature: 564 logger.warning("Since both `--key` and `--feature` are provided, `--feature` argument will be ignored.") 565 566 key = FeatureFlagConstants.FEATURE_FLAG_PREFIX + feature if key is None else key 567 # Get feature name from key for logging. If users have provided a different feature name, we ignore it anyway. 568 feature = key[len(FeatureFlagConstants.FEATURE_FLAG_PREFIX):] 569 570 azconfig_client = get_appconfig_data_client(cmd, name, connection_string, auth_mode, endpoint) 571 572 if index is None: 573 index = float("-inf") 574 575 # Construct feature filter to be added 576 if filter_parameters is None: 577 filter_parameters = {} 578 new_filter = FeatureFilter(filter_name, filter_parameters) 579 580 retry_times = 3 581 retry_interval = 1 582 for i in range(0, retry_times): 583 try: 584 retrieved_kv = azconfig_client.get_configuration_setting(key=key, label=label) 585 except ResourceNotFoundError: 586 raise CLIError("Feature flag '{}' with label '{}' not found.".format(feature, label)) 587 except HttpResponseError as exception: 588 raise CLIError("Failed to retrieve feature flags from config store. " + str(exception)) 589 590 try: 591 if retrieved_kv is None or retrieved_kv.content_type != FeatureFlagConstants.FEATURE_FLAG_CONTENT_TYPE: 592 raise CLIError( 593 "The feature flag {} does not exist.".format(feature)) 594 595 # we make sure that value retrieved is a valid json and only has the fields supported by backend. 596 # if it's invalid, we catch appropriate exception that contains 597 # detailed message 598 feature_flag_value = map_keyvalue_to_featureflagvalue(retrieved_kv) 599 feature_filters = feature_flag_value.conditions['client_filters'] 600 601 entry = json.dumps(new_filter.__dict__, indent=2, ensure_ascii=False) 602 confirmation_message = "Are you sure you want to add this filter?\n" + entry 603 user_confirmation(confirmation_message, yes) 604 605 # If user has specified index, we insert at that index 606 if 0 <= index <= len(feature_filters): 607 logger.debug("Adding new filter at index '%s'.\n", index) 608 feature_filters.insert(index, new_filter) 609 else: 610 if index != float("-inf"): 611 logger.debug( 612 "Ignoring the provided index '%s' because it is out of range or invalid.\n", index) 613 logger.debug("Adding new filter to the end of list.\n") 614 feature_filters.append(new_filter) 615 616 __update_existing_key_value(azconfig_client=azconfig_client, 617 retrieved_kv=retrieved_kv, 618 updated_value=json.dumps(feature_flag_value, 619 default=lambda o: o.__dict__, 620 ensure_ascii=False)) 621 622 return new_filter 623 624 except HttpResponseError as exception: 625 if exception.status_code == StatusCodes.PRECONDITION_FAILED: 626 logger.debug('Retrying filter add operation %s times with exception: concurrent setting operations', i + 1) 627 time.sleep(retry_interval) 628 else: 629 raise CLIError(str(exception)) 630 except Exception as exception: 631 raise CLIError(str(exception)) 632 raise CLIError( 633 "Failed to add filter for the feature flag '{}' due to a conflicting operation.".format(feature)) 634 635 636def delete_filter(cmd, 637 feature=None, 638 key=None, 639 filter_name=None, 640 name=None, 641 label=None, 642 index=None, 643 yes=False, 644 connection_string=None, 645 all_=False, 646 auth_mode="key", 647 endpoint=None): 648 if key is None and feature is None: 649 raise RequiredArgumentMissingError("Please provide either `--key` or `--feature` value.") 650 if key and feature: 651 logger.warning("Since both `--key` and `--feature` are provided, `--feature` argument will be ignored.") 652 653 key = FeatureFlagConstants.FEATURE_FLAG_PREFIX + feature if key is None else key 654 # Get feature name from key for logging. If users have provided a different feature name, we ignore it anyway. 655 feature = key[len(FeatureFlagConstants.FEATURE_FLAG_PREFIX):] 656 657 azconfig_client = get_appconfig_data_client(cmd, name, connection_string, auth_mode, endpoint) 658 659 if index is None: 660 index = float("-inf") 661 662 if all_: 663 return __clear_filter(azconfig_client, feature, label, yes) 664 665 if filter_name is None: 666 raise CLIError("Cannot delete filters because filter name is missing. To delete all filters, run the command again with --all option.") 667 668 retry_times = 3 669 retry_interval = 1 670 for i in range(0, retry_times): 671 try: 672 retrieved_kv = azconfig_client.get_configuration_setting(key=key, label=label) 673 except ResourceNotFoundError: 674 raise CLIError("Feature flag '{}' with label '{}' not found.".format(feature, label)) 675 except HttpResponseError as exception: 676 raise CLIError("Failed to retrieve feature flags from config store. " + str(exception)) 677 678 try: 679 if retrieved_kv is None or retrieved_kv.content_type != FeatureFlagConstants.FEATURE_FLAG_CONTENT_TYPE: 680 raise CLIError( 681 "The feature flag {} does not exist.".format(feature)) 682 683 # we make sure that value retrieved is a valid json and only has the fields supported by backend. 684 # if it's invalid, we catch appropriate exception that contains 685 # detailed message 686 feature_flag_value = map_keyvalue_to_featureflagvalue(retrieved_kv) 687 feature_filters = feature_flag_value.conditions['client_filters'] 688 689 display_filter = {} 690 match_index = [] 691 692 # get all filters where name matches filter_name provided by user 693 for idx, feature_filter in enumerate(feature_filters): 694 if feature_filter.name == filter_name: 695 if idx == index: 696 # name and index both match this filter - delete it. 697 # create a deep copy of the filter to display to the 698 # user after deletion 699 display_filter = copy.deepcopy(feature_filters[index]) 700 701 confirmation_message = "Are you sure you want to delete this filter?\n" + \ 702 json.dumps(display_filter.__dict__, indent=2, ensure_ascii=False) 703 user_confirmation(confirmation_message, yes) 704 705 del feature_filters[index] 706 break 707 708 match_index.append(idx) 709 710 if not display_filter: 711 # this means we have not deleted the filter yet 712 if len(match_index) == 1: 713 if index != float("-inf"): 714 logger.warning("Found filter '%s' at index '%s'. Invalidating provided index '%s'", filter_name, match_index[0], index) 715 716 display_filter = copy.deepcopy( 717 feature_filters[match_index[0]]) 718 719 confirmation_message = "Are you sure you want to delete this filter?\n" + \ 720 json.dumps(display_filter.__dict__, indent=2, ensure_ascii=False) 721 user_confirmation(confirmation_message, yes) 722 723 del feature_filters[match_index[0]] 724 725 elif len(match_index) > 1: 726 error_msg = "Feature '{0}' contains multiple instances of filter '{1}'. ".format(feature, filter_name) +\ 727 "For resolving this conflict run the command again with the filter name and correct zero-based index of the filter you want to delete.\n" 728 raise CLIError(str(error_msg)) 729 730 else: 731 raise CLIError( 732 "No filter named '{0}' was found for feature '{1}'".format(filter_name, feature)) 733 734 __update_existing_key_value(azconfig_client=azconfig_client, 735 retrieved_kv=retrieved_kv, 736 updated_value=json.dumps(feature_flag_value, 737 default=lambda o: o.__dict__, 738 ensure_ascii=False)) 739 740 return display_filter 741 742 except HttpResponseError as exception: 743 if exception.status_code == StatusCodes.PRECONDITION_FAILED: 744 logger.debug('Retrying deleting feature filter operation %s times with exception: concurrent setting operations', i + 1) 745 time.sleep(retry_interval) 746 else: 747 raise CLIError(str(exception)) 748 except Exception as exception: 749 raise CLIError(str(exception)) 750 raise CLIError( 751 "Failed to delete filter '{}' for the feature flag '{}' due to a conflicting operation.".format( 752 filter_name, 753 feature)) 754 755 756def show_filter(cmd, 757 filter_name, 758 feature=None, 759 key=None, 760 index=None, 761 name=None, 762 label=None, 763 connection_string=None, 764 auth_mode="key", 765 endpoint=None): 766 if key is None and feature is None: 767 raise RequiredArgumentMissingError("Please provide either `--key` or `--feature` value.") 768 if key and feature: 769 logger.warning("Since both `--key` and `--feature` are provided, `--feature` argument will be ignored.") 770 771 key = FeatureFlagConstants.FEATURE_FLAG_PREFIX + feature if key is None else key 772 # Get feature name from key for logging. If users have provided a different feature name, we ignore it anyway. 773 feature = key[len(FeatureFlagConstants.FEATURE_FLAG_PREFIX):] 774 775 azconfig_client = get_appconfig_data_client(cmd, name, connection_string, auth_mode, endpoint) 776 777 if index is None: 778 index = float("-inf") 779 780 try: 781 retrieved_kv = azconfig_client.get_configuration_setting(key=key, label=label) 782 except ResourceNotFoundError: 783 raise CLIError("Feature flag '{}' with label '{}' not found.".format(feature, label)) 784 except HttpResponseError as exception: 785 raise CLIError("Failed to retrieve feature flags from config store. " + str(exception)) 786 787 try: 788 if retrieved_kv is None or retrieved_kv.content_type != FeatureFlagConstants.FEATURE_FLAG_CONTENT_TYPE: 789 raise CLIError( 790 "The feature flag {} does not exist.".format(feature)) 791 792 # we make sure that value retrieved is a valid json and only has the fields supported by backend. 793 # if it's invalid, we catch appropriate exception that contains 794 # detailed message 795 feature_flag_value = map_keyvalue_to_featureflagvalue(retrieved_kv) 796 feature_filters = feature_flag_value.conditions['client_filters'] 797 798 display_filters = [] 799 800 # If user has specified index, we use it as secondary check to display 801 # a unique filter 802 if 0 <= index < len(feature_filters): 803 if feature_filters[index].name == filter_name: 804 return feature_filters[index] 805 if index != float("-inf"): 806 logger.warning( 807 "Could not find filter with the index provided. Ignoring index and trying to find the filter by name.") 808 809 # get all filters where name matches filter_name provided by user 810 display_filters = [ 811 featurefilter for featurefilter in feature_filters if featurefilter.name == filter_name] 812 813 if not display_filters: 814 raise CLIError( 815 "No filter named '{0}' was found for feature '{1}'".format(filter_name, feature)) 816 return display_filters 817 818 except Exception as exception: 819 raise CLIError(str(exception)) 820 821 822def list_filter(cmd, 823 feature=None, 824 key=None, 825 name=None, 826 label=None, 827 connection_string=None, 828 top=None, 829 all_=False, 830 auth_mode="key", 831 endpoint=None): 832 if key is None and feature is None: 833 raise RequiredArgumentMissingError("Please provide either `--key` or `--feature` value.") 834 if key and feature: 835 logger.warning("Since both `--key` and `--feature` are provided, `--feature` argument will be ignored.") 836 837 key = FeatureFlagConstants.FEATURE_FLAG_PREFIX + feature if key is None else key 838 # Get feature name from key for logging. If users have provided a different feature name, we ignore it anyway. 839 feature = key[len(FeatureFlagConstants.FEATURE_FLAG_PREFIX):] 840 841 azconfig_client = get_appconfig_data_client(cmd, name, connection_string, auth_mode, endpoint) 842 843 try: 844 retrieved_kv = azconfig_client.get_configuration_setting(key=key, label=label) 845 except ResourceNotFoundError: 846 raise CLIError("Feature flag '{}' with label '{}' not found.".format(feature, label)) 847 except HttpResponseError as exception: 848 raise CLIError("Failed to retrieve feature flags from config store. " + str(exception)) 849 850 try: 851 if retrieved_kv is None or retrieved_kv.content_type != FeatureFlagConstants.FEATURE_FLAG_CONTENT_TYPE: 852 raise CLIError( 853 "The feature flag {} does not exist.".format(feature)) 854 855 # we make sure that value retrieved is a valid json and only has the fields supported by backend. 856 # if it's invalid, we catch appropriate exception that contains 857 # detailed message 858 feature_flag_value = map_keyvalue_to_featureflagvalue(retrieved_kv) 859 feature_filters = feature_flag_value.conditions['client_filters'] 860 861 if all_: 862 top = len(feature_filters) 863 elif top is None: 864 top = 100 865 866 return feature_filters[:top] 867 868 except Exception as exception: 869 raise CLIError(str(exception)) 870 871 872# Helper functions # 873 874 875def __clear_filter(azconfig_client, 876 feature, 877 label=None, 878 yes=False): 879 key = FeatureFlagConstants.FEATURE_FLAG_PREFIX + feature 880 881 retry_times = 3 882 retry_interval = 1 883 for i in range(0, retry_times): 884 try: 885 retrieved_kv = azconfig_client.get_configuration_setting(key=key, label=label) 886 except ResourceNotFoundError: 887 raise CLIError("Feature flag '{}' with label '{}' not found.".format(feature, label)) 888 except HttpResponseError as exception: 889 raise CLIError("Failed to retrieve feature flags from config store. " + str(exception)) 890 891 try: 892 if retrieved_kv is None or retrieved_kv.content_type != FeatureFlagConstants.FEATURE_FLAG_CONTENT_TYPE: 893 raise CLIError( 894 "The feature flag {} does not exist.".format(feature)) 895 896 # we make sure that value retrieved is a valid json and only has the fields supported by backend. 897 # if it's invalid, we catch appropriate exception that contains 898 # detailed message 899 feature_flag_value = map_keyvalue_to_featureflagvalue(retrieved_kv) 900 901 # These fields will never be missing because we validate that 902 # in map_keyvalue_to_featureflagvalue 903 feature_filters = feature_flag_value.conditions['client_filters'] 904 905 # create a deep copy of the filters to display to the user 906 # after deletion 907 display_filters = [] 908 if feature_filters: 909 confirmation_message = "Are you sure you want to delete all filters for feature '{0}'?\n".format(feature) 910 user_confirmation(confirmation_message, yes) 911 912 display_filters = copy.deepcopy(feature_filters) 913 # clearing feature_filters list for python 2.7 compatibility 914 del feature_filters[:] 915 916 __update_existing_key_value(azconfig_client=azconfig_client, 917 retrieved_kv=retrieved_kv, 918 updated_value=json.dumps(feature_flag_value, 919 default=lambda o: o.__dict__, 920 ensure_ascii=False)) 921 922 return display_filters 923 924 except HttpResponseError as exception: 925 if exception.status_code == StatusCodes.PRECONDITION_FAILED: 926 logger.debug('Retrying feature enable operation %s times with exception: concurrent setting operations', i + 1) 927 time.sleep(retry_interval) 928 else: 929 raise CLIError(str(exception)) 930 except Exception as exception: 931 raise CLIError(str(exception)) 932 raise CLIError( 933 "Failed to delete filters for the feature flag '{}' due to a conflicting operation.".format(feature)) 934 935 936def __update_existing_key_value(azconfig_client, 937 retrieved_kv, 938 updated_value): 939 ''' 940 To update the value of a pre-existing KeyValue 941 942 Args: 943 azconfig_client - AppConfig client making calls to the service 944 retrieved_kv - Pre-existing ConfigurationSetting object 945 updated_value - Value string to be updated 946 947 Return: 948 KeyValue object 949 ''' 950 set_kv = ConfigurationSetting(key=retrieved_kv.key, 951 value=updated_value, 952 label=retrieved_kv.label, 953 tags=retrieved_kv.tags, 954 content_type=FeatureFlagConstants.FEATURE_FLAG_CONTENT_TYPE, 955 read_only=retrieved_kv.read_only, 956 etag=retrieved_kv.etag, 957 last_modified=retrieved_kv.last_modified) 958 959 try: 960 new_kv = azconfig_client.set_configuration_setting(set_kv, match_condition=MatchConditions.IfNotModified) 961 return convert_configurationsetting_to_keyvalue(new_kv) 962 except ResourceReadOnlyError: 963 raise CLIError("Failed to update read only feature flag. Unlock the feature flag before updating it.") 964 # We don't catch HttpResponseError here because some calling functions want to retry on transient exceptions 965 966 967def __list_all_keyvalues(azconfig_client, 968 key_filter, 969 label=None): 970 ''' 971 To get all keys by name or pattern 972 973 Args: 974 azconfig_client - AppConfig client making calls to the service 975 key_filter - Filter for the key of the feature flag 976 label - Feature label or pattern 977 978 Return: 979 List of KeyValue objects 980 ''' 981 982 # We dont support listing comma separated keys and ned to fail with appropriate error 983 # (?<!\\) Matches if the preceding character is not a backslash 984 # (?:\\\\)* Matches any number of occurrences of two backslashes 985 # , Matches a comma 986 unescaped_comma_regex = re.compile(r'(?<!\\)(?:\\\\)*,') 987 if unescaped_comma_regex.search(key_filter): 988 raise CLIError("Comma separated feature names are not supported. Please provide escaped string if your feature name contains comma. \nSee \"az appconfig feature list -h\" for correct usage.") 989 990 label = prep_label_filter_for_url_encoding(label) 991 992 try: 993 configsetting_iterable = azconfig_client.list_configuration_settings(key_filter=key_filter, label_filter=label) 994 except HttpResponseError as exception: 995 raise CLIError('Failed to read feature flag(s) that match the specified feature and label. ' + str(exception)) 996 997 try: 998 retrieved_kv = [convert_configurationsetting_to_keyvalue(x) for x in configsetting_iterable] 999 valid_features = [] 1000 for kv in retrieved_kv: 1001 if kv.content_type == FeatureFlagConstants.FEATURE_FLAG_CONTENT_TYPE: 1002 valid_features.append(kv) 1003 return valid_features 1004 except Exception as exception: 1005 raise CLIError(str(exception)) 1006