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