diff --git a/src/azure-cli/azure/cli/command_modules/serviceconnector/_params.py b/src/azure-cli/azure/cli/command_modules/serviceconnector/_params.py index 5a325590e1c..cae3435452f 100644 --- a/src/azure-cli/azure/cli/command_modules/serviceconnector/_params.py +++ b/src/azure-cli/azure/cli/command_modules/serviceconnector/_params.py @@ -191,6 +191,12 @@ def add_connstr_props_argument(context): validator=validate_connstr_props) +def add_no_recreate_arguments(context): + context.argument('no_recreate', options_list=['--no-recreate'], + arg_type=get_three_state_flag(), default=False, + help='Skip executing creation operation when no updates to an existing connection.') + + def add_target_type_argument(context, source): TARGET_TYPES = [ elem.value for elem in SUPPORTED_AUTH_TYPE.get(source).keys()] @@ -321,6 +327,7 @@ def load_arguments(self, _): # pylint: disable=too-many-statements add_customized_keys_argument(c) add_opt_out_argument(c) add_connstr_props_argument(c) + add_no_recreate_arguments(c) with self.argument_context('{} connection update {}'.format(source.value, target.value)) as c: add_client_type_argument(c, source, target) diff --git a/src/azure-cli/azure/cli/command_modules/serviceconnector/_utils.py b/src/azure-cli/azure/cli/command_modules/serviceconnector/_utils.py index 6b8f7832a33..5dfd7aeaddf 100644 --- a/src/azure-cli/azure/cli/command_modules/serviceconnector/_utils.py +++ b/src/azure-cli/azure/cli/command_modules/serviceconnector/_utils.py @@ -14,8 +14,6 @@ ) # from azure.cli.core._profile import Profile from ._resource_config import ( - SOURCE_RESOURCES_USERTOKEN, - TARGET_RESOURCES_USERTOKEN, RESOURCE ) from azure.cli.core import get_default_cli @@ -126,8 +124,9 @@ def set_user_token_header(client, cli_ctx): def set_user_token_by_source_and_target(client, cli_ctx, source, target): '''Set user token header to work around OBO according to source and target''' - if source in SOURCE_RESOURCES_USERTOKEN or target in TARGET_RESOURCES_USERTOKEN: - return set_user_token_header(client, cli_ctx) + # deprecated + # if source in SOURCE_RESOURCES_USERTOKEN or target in TARGET_RESOURCES_USERTOKEN: + # return set_user_token_header(client, cli_ctx) return client @@ -566,10 +565,49 @@ def get_aks_resource_name(linker): not (linker["targetService"]["resourceProperties"] is not None and linker["targetService"]["resourceProperties"].get("connectAsKubernetesCsiDriver")): service_account_name = f'sc-account-{linker["authInfo"].get("clientId")}' - return [secret_name, service_account_name] - return [secret_name] + return {'secret': secret_name, 'serviceAccount': service_account_name} + return {'secret': secret_name} def get_aks_resource_secret_name(connection_name): valid_name = re.sub(r'[^a-zA-Z0-9]', '', connection_name, flags=re.IGNORECASE) return f'sc-{valid_name}-secret' + + +def compare_properties_changed(new_props, existing_props): + """ + Deep comparison function that checks if there are meaningful differences + between new properties and existing properties, ignoring None values. + Returns True if there are differences that require an update. + """ + def has_meaningful_changes(new_value, existing_value): + if new_value is None or new_value == "": + return False + + if isinstance(new_value, dict) and isinstance(existing_value, dict): + for key, value in new_value.items(): + if key == "mysql-identity-id" or key == 'user_object_id': + continue + existing_props_key = key + if '_' in key: + existing_props_key = to_camel_case(key) + existing_nested = existing_value.get(existing_props_key) if existing_value else None + if has_meaningful_changes(value, existing_nested): + return True + return False + if isinstance(new_value, dict) and existing_value is None: + for value in new_value.values(): + if value is not None and value != "": + return True + return False + + return new_value != existing_value + + return has_meaningful_changes(new_props, existing_props) + + +def to_camel_case(snake_str): + if not snake_str or '_' not in snake_str: + return snake_str + components = snake_str.split('_') + return components[0] + ''.join(word.capitalize() for word in components[1:]) diff --git a/src/azure-cli/azure/cli/command_modules/serviceconnector/custom.py b/src/azure-cli/azure/cli/command_modules/serviceconnector/custom.py index 26a813b4fe4..467d6e98816 100644 --- a/src/azure-cli/azure/cli/command_modules/serviceconnector/custom.py +++ b/src/azure-cli/azure/cli/command_modules/serviceconnector/custom.py @@ -14,6 +14,7 @@ ValidationError, AzureResponseError ) +from azure.core.exceptions import ResourceNotFoundError from ._resource_config import ( CLIENT_TYPE, SUPPORTED_AUTH_TYPE, @@ -31,6 +32,7 @@ ) from ._addon_factory import AddonFactory from ._utils import ( + compare_properties_changed, set_user_token_by_source_and_target, set_user_token_header, auto_register, @@ -43,7 +45,7 @@ get_secret_type_for_update ) from ._credential_free import is_passwordless_command -# pylint: disable=unused-argument,unsubscriptable-object,unsupported-membership-test,too-many-statements,too-many-locals +# pylint: disable=unused-argument,unsubscriptable-object,unsupported-membership-test,too-many-statements,too-many-locals,broad-exception-caught logger = get_logger(__name__) @@ -317,7 +319,8 @@ def connection_create(cmd, client, # pylint: disable=too-many-locals,too-many-s target_app_name=None, # Resource.ContainerApp connstr_props=None, # Resource.FabricSql fabric_workspace_uuid=None, - fabric_sql_db_uuid=None + fabric_sql_db_uuid=None, + no_recreate=False, ): auth_action = 'optOutAllAuth' if (opt_out_list is not None and OPT_OUT_OPTION.AUTHENTICATION.value in opt_out_list) else None @@ -370,12 +373,13 @@ def connection_create(cmd, client, # pylint: disable=too-many-locals,too-many-s app_config_id=app_config_id, enable_appconfig_extension=enable_appconfig_extension, server=server, database=database, - connstr_props=connstr_props + connstr_props=connstr_props, + no_recreate=no_recreate, ) # The function is used in extension, new feature must be added in the end for backward compatibility -def connection_create_func(cmd, client, # pylint: disable=too-many-locals,too-many-statements +def connection_create_func(cmd, client, # pylint: disable=too-many-locals,too-many-statements,too-many-branches connection_name=None, client_type=None, source_resource_group=None, source_id=None, target_resource_group=None, target_id=None, @@ -406,6 +410,7 @@ def connection_create_func(cmd, client, # pylint: disable=too-many-locals,too-m target_app_name=None, # Resource.ContainerApp enable_appconfig_extension=False, connstr_props=None, # Resource.FabricSql + no_recreate=False, **kwargs, ): if not source_id: @@ -530,6 +535,20 @@ def connection_create_func(cmd, client, # pylint: disable=too-many-locals,too-m 'manually and then create the connection.'.format(str(e))) validate_service_state(parameters) + if no_recreate: + try: + linker = todict(client.get(resource_uri=source_id, linker_name=connection_name)) + if linker is not None and linker.get('provisioningState') == 'Accepted': + logger.warning('Connection provisioningState is Accepted, please retry later to avoid conflict.') + return linker + if linker is not None and linker.get('provisioningState') == 'Succeeded' and \ + not compare_properties_changed(parameters, linker): + logger.warning( + 'Connection exists and no property to be updated, skip the update operation.') + return linker + except ResourceNotFoundError: + logger.debug('No existing connection, continue to create it.') + if enable_mi_for_db_linker and auth_action != 'optOutAllAuth': new_auth_info = enable_mi_for_db_linker( cmd, source_id, target_id, auth_info, client_type, connection_name, connstr_props)