diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_help.py b/src/azure-cli/azure/cli/command_modules/appservice/_help.py index e0dad92e98c..585033170ba 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_help.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_help.py @@ -1607,6 +1607,9 @@ - name: Set a web app container's settings. (autogenerated) text: az webapp config container set --docker-custom-image-name MyDockerCustomImage --docker-registry-server-password StrongPassword --docker-registry-server-url https://{azure-container-registry-name}.azurecr.io --docker-registry-server-user DockerUserId --name MyWebApp --resource-group MyResourceGroup crafted: true + - name: Set a web app container to pull from ACR using a managed identity. + text: az webapp config container set --container-image-name myregistry.azurecr.io/myimage:latest --container-registry-url https://myregistry.azurecr.io --assign-identity [system] --acr-use-identity --acr-identity [system] --name MyWebApp --resource-group MyResourceGroup + crafted: true """ helps['webapp config container show'] = """ diff --git a/src/azure-cli/azure/cli/command_modules/appservice/_params.py b/src/azure-cli/azure/cli/command_modules/appservice/_params.py index bd4dc0b7ea5..47bcea97db2 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/_params.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/_params.py @@ -646,6 +646,23 @@ def load_arguments(self, _): c.ignore('min_replicas') c.ignore('max_replicas') + with self.argument_context('webapp config container set') as c: + c.argument('assign_identities', nargs='*', options_list=['--assign-identity'], + help="Accept system or user assigned identities separated by spaces. " + "Use '[system]' to refer to system assigned identity, " + "or a resource id to refer to user assigned identity. " + "Check out help for more examples") + c.argument('scope', options_list=['--scope'], + help="Scope that the system assigned identity can access") + c.argument('role', options_list=['--role'], + help="Role name or id the system assigned identity will have") + c.argument('acr_use_identity', arg_type=get_three_state_flag(return_label=True), + help="Enable or disable pulling images from ACR using a managed identity") + c.argument('acr_identity', + help="Accept system or user assigned identity which will be used for ACR image pull. " + "Use '[system]' to refer to system assigned identity, " + "or a resource id to refer to user assigned identity.") + with self.argument_context('functionapp config container') as c: c.argument('registry_server', options_list=['--registry-server', '-r', c.deprecate(target='--docker-registry-server-url', redirect='--registry-server')], help='the container registry server url') diff --git a/src/azure-cli/azure/cli/command_modules/appservice/custom.py b/src/azure-cli/azure/cli/command_modules/appservice/custom.py index 386ba088608..d1bffe9781b 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/custom.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/custom.py @@ -3598,47 +3598,105 @@ def _redact_connection_strings(properties): APPSETTINGS_TO_MASK = ['DOCKER_REGISTRY_SERVER_PASSWORD'] -def update_container_settings(cmd, resource_group_name, name, container_registry_url=None, - container_image_name=None, container_registry_user=None, - websites_enable_app_service_storage=None, container_registry_password=None, - multicontainer_config_type=None, multicontainer_config_file=None, - slot=None, min_replicas=None, max_replicas=None): - settings = [] - if container_registry_url is not None: - settings.append('DOCKER_REGISTRY_SERVER_URL=' + container_registry_url) +def _is_key_vault_reference(value): + """Check if a setting value is a Key Vault reference.""" + return isinstance(value, str) and value.strip().startswith('@Microsoft.KeyVault(') - if (not container_registry_user and not container_registry_password and - container_registry_url and '.azurecr.io' in container_registry_url): - logger.warning('No credential was provided to access Azure Container Registry. Trying to look up...') - parsed = urlparse(container_registry_url) - registry_name = (parsed.netloc if parsed.scheme else parsed.path).split('.')[0] - try: - container_registry_user, container_registry_password = _get_acr_cred(cmd.cli_ctx, registry_name) - except Exception as ex: # pylint: disable=broad-except - logger.warning("Retrieving credentials failed with an exception:'%s'", ex) # consider throw if needed +def _build_container_updates(container_registry_url, container_registry_user, + container_registry_password, websites_enable_app_service_storage): + """Build dict of container-specific app settings that were explicitly provided.""" + updates = {} + if container_registry_url is not None: + updates['DOCKER_REGISTRY_SERVER_URL'] = container_registry_url if container_registry_user is not None: - settings.append('DOCKER_REGISTRY_SERVER_USERNAME=' + container_registry_user) + updates['DOCKER_REGISTRY_SERVER_USERNAME'] = container_registry_user if container_registry_password is not None: - settings.append('DOCKER_REGISTRY_SERVER_PASSWORD=' + container_registry_password) + updates['DOCKER_REGISTRY_SERVER_PASSWORD'] = container_registry_password if websites_enable_app_service_storage: - settings.append('WEBSITES_ENABLE_APP_SERVICE_STORAGE=' + websites_enable_app_service_storage) + updates['WEBSITES_ENABLE_APP_SERVICE_STORAGE'] = websites_enable_app_service_storage + return updates + - if container_registry_user or container_registry_password or container_registry_url or websites_enable_app_service_storage: # pylint: disable=line-too-long - update_app_settings(cmd, resource_group_name, name, settings, slot) +def update_container_settings(cmd, resource_group_name, name, container_registry_url=None, + container_image_name=None, container_registry_user=None, + websites_enable_app_service_storage=None, container_registry_password=None, + multicontainer_config_type=None, multicontainer_config_file=None, + slot=None, min_replicas=None, max_replicas=None, + assign_identities=None, role='AcrPull', scope=None, + acr_use_identity=None, acr_identity=None): + # Read existing app settings so we can preserve non-container settings and Key Vault references + existing_app_settings = _generic_site_operation(cmd.cli_ctx, resource_group_name, name, + 'list_application_settings', slot) + existing_properties = existing_app_settings.properties or {} + + # Skip credential lookup entirely when managed identity ACR pull is enabled + if acr_use_identity: + logger.info('Managed identity ACR pull is enabled; skipping automatic credential lookup.') + elif (not container_registry_user and not container_registry_password and + container_registry_url and '.azurecr.io' in container_registry_url): + existing_user_val = existing_properties.get('DOCKER_REGISTRY_SERVER_USERNAME', '') + existing_pass_val = existing_properties.get('DOCKER_REGISTRY_SERVER_PASSWORD', '') + if _is_key_vault_reference(existing_user_val) or _is_key_vault_reference(existing_pass_val): + logger.warning('Existing registry credentials use Key Vault references. ' + 'Skipping automatic credential lookup.') + else: + logger.warning('No credential was provided to access Azure Container Registry. ' + 'Trying to look up...') + parsed = urlparse(container_registry_url) + registry_name = (parsed.netloc if parsed.scheme else parsed.path).split('.')[0] + try: + container_registry_user, container_registry_password = _get_acr_cred(cmd.cli_ctx, + registry_name) + except Exception as ex: # pylint: disable=broad-except + logger.warning("Retrieving credentials failed with an exception:'%s'", ex) + + # Build dict of only the container-specific settings that were explicitly provided + container_updates = _build_container_updates(container_registry_url, container_registry_user, + container_registry_password, + websites_enable_app_service_storage) + + if container_updates: + # Merge only container-specific keys into the existing settings, + # preserving all other app settings (including Key Vault references) as-is + for key, value in container_updates.items(): + existing_app_settings.properties[key] = value + client = web_client_factory(cmd.cli_ctx) + if is_centauri_functionapp(cmd, resource_group_name, name): + update_application_settings_polling(cmd, resource_group_name, name, + existing_app_settings, slot, client) + else: + _generic_settings_operation(cmd.cli_ctx, resource_group_name, name, + 'update_application_settings', + existing_app_settings, slot, client) settings = get_app_settings(cmd, resource_group_name, name, slot) if container_image_name is not None: _add_fx_version(cmd, resource_group_name, name, container_image_name, slot) + # Accumulate site-config changes and issue a single update_site_configs call + site_config_kwargs = {} if multicontainer_config_file and multicontainer_config_type: encoded_config_file = _get_linux_multicontainer_encoded_config_from_file(multicontainer_config_file) - linux_fx_version = _format_fx_version(encoded_config_file, multicontainer_config_type) - update_site_configs(cmd, resource_group_name, name, linux_fx_version=linux_fx_version, slot=slot) + site_config_kwargs['linux_fx_version'] = _format_fx_version(encoded_config_file, + multicontainer_config_type) elif multicontainer_config_file or multicontainer_config_type: logger.warning('Must change both settings --multicontainer-config-file FILE --multicontainer-config-type TYPE') - if min_replicas is not None or max_replicas is not None: - update_site_configs(cmd, resource_group_name, name, min_replicas=min_replicas, max_replicas=max_replicas) + if min_replicas is not None: + site_config_kwargs['min_replicas'] = min_replicas + if max_replicas is not None: + site_config_kwargs['max_replicas'] = max_replicas + + if acr_use_identity is not None: + site_config_kwargs['acr_use_identity'] = acr_use_identity + if acr_identity is not None: + site_config_kwargs['acr_identity'] = acr_identity + + if site_config_kwargs: + update_site_configs(cmd, resource_group_name, name, slot=slot, **site_config_kwargs) + + if assign_identities is not None: + assign_identity(cmd, resource_group_name, name, assign_identities, role, slot, scope) return _mask_creds_related_appsettings(_filter_for_container_settings(cmd, resource_group_name, name, settings, slot=slot)) diff --git a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py index 853eadc1edd..ca78b7ebb6a 100644 --- a/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py +++ b/src/azure-cli/azure/cli/command_modules/appservice/tests/latest/test_webapp_commands_thru_mock.py @@ -33,7 +33,9 @@ add_github_actions, update_app_settings, update_application_settings_polling, - update_webapp) + update_webapp, + update_container_settings, + _is_key_vault_reference) # pylint: disable=line-too-long from azure.cli.core.profiles import ResourceType @@ -639,6 +641,284 @@ def test_update_webapp_platform_release_channel_latest(self): self.assertEqual(result.additional_properties["properties"]["platformReleaseChannel"], "Latest") +class TestUpdateContainerSettingsIdentity(unittest.TestCase): + """Tests for managed identity support in update_container_settings.""" + + def _make_mock_app_settings(self, properties=None): + mock_app_settings = mock.MagicMock() + mock_app_settings.properties = properties or {} + return mock_app_settings + + @mock.patch('azure.cli.command_modules.appservice.custom._get_acr_cred') + @mock.patch('azure.cli.command_modules.appservice.custom._mask_creds_related_appsettings') + @mock.patch('azure.cli.command_modules.appservice.custom._filter_for_container_settings') + @mock.patch('azure.cli.command_modules.appservice.custom.get_app_settings', return_value=[]) + @mock.patch('azure.cli.command_modules.appservice.custom._generic_settings_operation') + @mock.patch('azure.cli.command_modules.appservice.custom.is_centauri_functionapp', return_value=False) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + @mock.patch('azure.cli.command_modules.appservice.custom._add_fx_version') + @mock.patch('azure.cli.command_modules.appservice.custom.assign_identity') + @mock.patch('azure.cli.command_modules.appservice.custom.update_site_configs') + def test_container_set_with_system_identity_and_acr( + self, mock_update_site_configs, mock_assign_identity, + mock_add_fx, mock_site_op, mock_client_factory, mock_centauri, + mock_settings_op, mock_get_app, mock_filter, mock_mask, + mock_get_acr_cred): + mock_mask.return_value = {} + mock_site_op.return_value = self._make_mock_app_settings() + cmd = _get_test_cmd() + update_container_settings( + cmd, 'rg', 'web1', + container_registry_url='https://myregistry.azurecr.io', + container_image_name='myregistry.azurecr.io/myimage:latest', + assign_identities=['[system]'], + acr_use_identity='true', + acr_identity='[system]') + mock_get_acr_cred.assert_not_called() + mock_assign_identity.assert_called_once_with( + cmd, 'rg', 'web1', ['[system]'], 'AcrPull', None, None) + mock_update_site_configs.assert_called_once_with( + cmd, 'rg', 'web1', slot=None, + acr_use_identity='true', acr_identity='[system]') + + @mock.patch('azure.cli.command_modules.appservice.custom._get_acr_cred') + @mock.patch('azure.cli.command_modules.appservice.custom._mask_creds_related_appsettings') + @mock.patch('azure.cli.command_modules.appservice.custom._filter_for_container_settings') + @mock.patch('azure.cli.command_modules.appservice.custom.get_app_settings', return_value=[]) + @mock.patch('azure.cli.command_modules.appservice.custom._generic_settings_operation') + @mock.patch('azure.cli.command_modules.appservice.custom.is_centauri_functionapp', return_value=False) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + @mock.patch('azure.cli.command_modules.appservice.custom._add_fx_version') + @mock.patch('azure.cli.command_modules.appservice.custom.assign_identity') + @mock.patch('azure.cli.command_modules.appservice.custom.update_site_configs') + def test_container_set_with_user_identity( + self, mock_update_site_configs, mock_assign_identity, + mock_add_fx, mock_site_op, mock_client_factory, mock_centauri, + mock_settings_op, mock_get_app, mock_filter, mock_mask, + mock_get_acr_cred): + mock_mask.return_value = {} + mock_site_op.return_value = self._make_mock_app_settings() + cmd = _get_test_cmd() + user_identity = '/subscriptions/sub1/resourcegroups/rg1/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id1' + update_container_settings( + cmd, 'rg', 'web1', + container_registry_url='https://myregistry.azurecr.io', + container_image_name='myregistry.azurecr.io/myimage:latest', + assign_identities=[user_identity], + acr_use_identity='true', + acr_identity=user_identity) + mock_get_acr_cred.assert_not_called() + mock_assign_identity.assert_called_once_with( + cmd, 'rg', 'web1', [user_identity], 'AcrPull', None, None) + mock_update_site_configs.assert_called_once_with( + cmd, 'rg', 'web1', slot=None, + acr_use_identity='true', acr_identity=user_identity) + + @mock.patch('azure.cli.command_modules.appservice.custom._mask_creds_related_appsettings') + @mock.patch('azure.cli.command_modules.appservice.custom._filter_for_container_settings') + @mock.patch('azure.cli.command_modules.appservice.custom.get_app_settings', return_value=[]) + @mock.patch('azure.cli.command_modules.appservice.custom._generic_settings_operation') + @mock.patch('azure.cli.command_modules.appservice.custom.is_centauri_functionapp', return_value=False) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + @mock.patch('azure.cli.command_modules.appservice.custom._add_fx_version') + @mock.patch('azure.cli.command_modules.appservice.custom.assign_identity') + @mock.patch('azure.cli.command_modules.appservice.custom.update_site_configs') + def test_container_set_without_identity_does_not_call_identity_apis( + self, mock_update_site_configs, mock_assign_identity, + mock_add_fx, mock_site_op, mock_client_factory, mock_centauri, + mock_settings_op, mock_get_app, mock_filter, mock_mask): + mock_mask.return_value = {} + mock_site_op.return_value = self._make_mock_app_settings() + cmd = _get_test_cmd() + update_container_settings( + cmd, 'rg', 'web1', + container_registry_url='https://myregistry.azurecr.io', + container_image_name='myregistry.azurecr.io/myimage:latest', + container_registry_user='user', + container_registry_password='pass') + mock_assign_identity.assert_not_called() + mock_update_site_configs.assert_not_called() + + @mock.patch('azure.cli.command_modules.appservice.custom._get_acr_cred') + @mock.patch('azure.cli.command_modules.appservice.custom._mask_creds_related_appsettings') + @mock.patch('azure.cli.command_modules.appservice.custom._filter_for_container_settings') + @mock.patch('azure.cli.command_modules.appservice.custom.get_app_settings', return_value=[]) + @mock.patch('azure.cli.command_modules.appservice.custom._generic_settings_operation') + @mock.patch('azure.cli.command_modules.appservice.custom.is_centauri_functionapp', return_value=False) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + @mock.patch('azure.cli.command_modules.appservice.custom._add_fx_version') + @mock.patch('azure.cli.command_modules.appservice.custom.assign_identity') + @mock.patch('azure.cli.command_modules.appservice.custom.update_site_configs') + def test_container_set_with_custom_role( + self, mock_update_site_configs, mock_assign_identity, + mock_add_fx, mock_site_op, mock_client_factory, mock_centauri, + mock_settings_op, mock_get_app, mock_filter, mock_mask, + mock_get_acr_cred): + mock_mask.return_value = {} + mock_site_op.return_value = self._make_mock_app_settings() + cmd = _get_test_cmd() + update_container_settings( + cmd, 'rg', 'web1', + container_registry_url='https://myregistry.azurecr.io', + container_image_name='myregistry.azurecr.io/myimage:latest', + assign_identities=['[system]'], + role='Reader', + acr_use_identity='true', + acr_identity='[system]') + mock_get_acr_cred.assert_not_called() + mock_assign_identity.assert_called_once_with( + cmd, 'rg', 'web1', ['[system]'], 'Reader', None, None) + mock_update_site_configs.assert_called_once_with( + cmd, 'rg', 'web1', slot=None, + acr_use_identity='true', acr_identity='[system]') + + @mock.patch('azure.cli.command_modules.appservice.custom._get_acr_cred') + @mock.patch('azure.cli.command_modules.appservice.custom._mask_creds_related_appsettings') + @mock.patch('azure.cli.command_modules.appservice.custom._filter_for_container_settings') + @mock.patch('azure.cli.command_modules.appservice.custom.get_app_settings', return_value=[]) + @mock.patch('azure.cli.command_modules.appservice.custom._generic_settings_operation') + @mock.patch('azure.cli.command_modules.appservice.custom.is_centauri_functionapp', return_value=False) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + @mock.patch('azure.cli.command_modules.appservice.custom._add_fx_version') + @mock.patch('azure.cli.command_modules.appservice.custom.assign_identity') + @mock.patch('azure.cli.command_modules.appservice.custom.update_site_configs') + def test_container_set_disable_acr_identity( + self, mock_update_site_configs, mock_assign_identity, + mock_add_fx, mock_site_op, mock_client_factory, mock_centauri, + mock_settings_op, mock_get_app, mock_filter, mock_mask, + mock_get_acr_cred): + mock_mask.return_value = {} + mock_site_op.return_value = self._make_mock_app_settings() + cmd = _get_test_cmd() + update_container_settings( + cmd, 'rg', 'web1', + container_registry_url='https://myregistry.azurecr.io', + acr_use_identity='false') + mock_assign_identity.assert_not_called() + mock_update_site_configs.assert_called_once_with( + cmd, 'rg', 'web1', slot=None, + acr_use_identity='false') + + @mock.patch('azure.cli.command_modules.appservice.custom._get_acr_cred') + @mock.patch('azure.cli.command_modules.appservice.custom._mask_creds_related_appsettings') + @mock.patch('azure.cli.command_modules.appservice.custom._filter_for_container_settings') + @mock.patch('azure.cli.command_modules.appservice.custom.get_app_settings', return_value=[]) + @mock.patch('azure.cli.command_modules.appservice.custom._generic_settings_operation') + @mock.patch('azure.cli.command_modules.appservice.custom.is_centauri_functionapp', return_value=False) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + @mock.patch('azure.cli.command_modules.appservice.custom._add_fx_version') + @mock.patch('azure.cli.command_modules.appservice.custom.assign_identity') + @mock.patch('azure.cli.command_modules.appservice.custom.update_site_configs') + def test_acr_use_identity_skips_credential_lookup( + self, mock_update_site_configs, mock_assign_identity, + mock_add_fx, mock_site_op, mock_client_factory, mock_centauri, + mock_settings_op, mock_get_app, mock_filter, mock_mask, + mock_get_acr_cred): + """When acr_use_identity is set, _get_acr_cred should NOT be called.""" + mock_mask.return_value = {} + mock_site_op.return_value = self._make_mock_app_settings() + cmd = _get_test_cmd() + update_container_settings( + cmd, 'rg', 'web1', + container_registry_url='https://myregistry.azurecr.io', + container_image_name='myregistry.azurecr.io/myimage:latest', + assign_identities=['[system]'], + acr_use_identity='true', + acr_identity='[system]') + mock_get_acr_cred.assert_not_called() + + +class TestUpdateContainerSettingsPreservesKeyVaultRefs(unittest.TestCase): + + @mock.patch('azure.cli.command_modules.appservice.custom._get_fx_version', return_value='DOCKER|myimage:latest') + @mock.patch('azure.cli.command_modules.appservice.custom.get_app_settings') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_settings_operation') + @mock.patch('azure.cli.command_modules.appservice.custom.is_centauri_functionapp', return_value=False) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + def test_container_set_preserves_kv_ref_settings(self, mock_site_op, mock_client_factory, + mock_centauri, mock_settings_op, + mock_get_app_settings, mock_get_fx): + """Key Vault reference app settings must survive az webapp config container set.""" + cmd_mock = _get_test_cmd() + + kv_ref = '@Microsoft.KeyVault(SecretUri=https://myvault.vault.azure.net/secrets/mysecret)' + existing_properties = { + 'MY_KV_SECRET': kv_ref, + 'NORMAL_SETTING': 'normal_value', + 'DOCKER_REGISTRY_SERVER_URL': 'https://old.azurecr.io', + } + mock_app_settings = mock.MagicMock() + mock_app_settings.properties = dict(existing_properties) + mock_site_op.return_value = mock_app_settings + + mock_get_app_settings.return_value = [ + {'name': 'MY_KV_SECRET', 'value': kv_ref, 'slotSetting': False}, + {'name': 'NORMAL_SETTING', 'value': 'normal_value', 'slotSetting': False}, + {'name': 'DOCKER_REGISTRY_SERVER_URL', 'value': 'https://new.example.io', 'slotSetting': False}, + ] + + update_container_settings(cmd_mock, 'test-rg', 'test-app', + container_registry_url='https://new.example.io') + + # The settings written back must still contain the KV ref and normal setting + written_props = mock_app_settings.properties + self.assertEqual(written_props['MY_KV_SECRET'], kv_ref) + self.assertEqual(written_props['NORMAL_SETTING'], 'normal_value') + self.assertEqual(written_props['DOCKER_REGISTRY_SERVER_URL'], 'https://new.example.io') + + @mock.patch('azure.cli.command_modules.appservice.custom._get_fx_version', return_value='DOCKER|myimage:latest') + @mock.patch('azure.cli.command_modules.appservice.custom.get_app_settings') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_settings_operation') + @mock.patch('azure.cli.command_modules.appservice.custom.is_centauri_functionapp', return_value=False) + @mock.patch('azure.cli.command_modules.appservice.custom.web_client_factory') + @mock.patch('azure.cli.command_modules.appservice.custom._generic_site_operation') + def test_container_set_skips_acr_auto_detect_when_kv_refs(self, mock_site_op, mock_client_factory, + mock_centauri, mock_settings_op, + mock_get_app_settings, mock_get_fx): + """ACR credential auto-detection must be skipped when existing creds are KV references.""" + cmd_mock = _get_test_cmd() + + kv_user = '@Microsoft.KeyVault(SecretUri=https://vault.vault.azure.net/secrets/user)' + kv_pass = '@Microsoft.KeyVault(SecretUri=https://vault.vault.azure.net/secrets/pass)' + existing_properties = { + 'DOCKER_REGISTRY_SERVER_URL': 'https://old.azurecr.io', + 'DOCKER_REGISTRY_SERVER_USERNAME': kv_user, + 'DOCKER_REGISTRY_SERVER_PASSWORD': kv_pass, + } + mock_app_settings = mock.MagicMock() + mock_app_settings.properties = dict(existing_properties) + mock_site_op.return_value = mock_app_settings + + mock_get_app_settings.return_value = [ + {'name': 'DOCKER_REGISTRY_SERVER_URL', 'value': 'https://myregistry.azurecr.io', 'slotSetting': False}, + ] + + with mock.patch('azure.cli.command_modules.appservice.custom._get_acr_cred') as mock_acr_cred: + update_container_settings(cmd_mock, 'test-rg', 'test-app', + container_registry_url='https://myregistry.azurecr.io') + # _get_acr_cred should NOT have been called because existing creds are KV refs + mock_acr_cred.assert_not_called() + + # Existing KV references for username/password must be preserved + written_props = mock_app_settings.properties + self.assertEqual(written_props['DOCKER_REGISTRY_SERVER_USERNAME'], kv_user) + self.assertEqual(written_props['DOCKER_REGISTRY_SERVER_PASSWORD'], kv_pass) + + def test_is_key_vault_reference_detects_kv_refs(self): + self.assertTrue(_is_key_vault_reference('@Microsoft.KeyVault(SecretUri=https://v.vault.azure.net/secrets/s)')) + self.assertTrue(_is_key_vault_reference(' @Microsoft.KeyVault(VaultName=v;SecretName=s)')) + self.assertFalse(_is_key_vault_reference('plain_value')) + self.assertFalse(_is_key_vault_reference('')) + self.assertFalse(_is_key_vault_reference(None)) + + class FakedResponse: # pylint: disable=too-few-public-methods def __init__(self, status_code): self.status_code = status_code