diff --git a/src/azure-cli/azure/cli/command_modules/acr/_client_factory.py b/src/azure-cli/azure/cli/command_modules/acr/_client_factory.py index e07842e0e4f..a961abf450d 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/_client_factory.py +++ b/src/azure-cli/azure/cli/command_modules/acr/_client_factory.py @@ -5,11 +5,18 @@ from azure.cli.core.commands.client_factory import get_mgmt_service_client +REGISTRY_TENANT_USAGE_ERROR = \ + "--registry-tenant can only be used when --registry is a container registry resource ID." +REGISTRY_TENANT_AUTH_ERROR = \ + "--registry-tenant is not supported when signed in with managed identity or Cloud Shell. " \ + "Sign in with a user or service principal that has access to both tenants." -def get_acr_service_client(cli_ctx, api_version=None): + +def get_acr_service_client(cli_ctx, api_version=None, aux_tenants=None): """Returns the client for managing container registries. """ from azure.cli.core.profiles import ResourceType - return get_mgmt_service_client(cli_ctx, ResourceType.MGMT_CONTAINERREGISTRY, api_version=api_version) + return get_mgmt_service_client(cli_ctx, ResourceType.MGMT_CONTAINERREGISTRY, api_version=api_version, + aux_tenants=aux_tenants) def get_acr_tasks_service_client(cli_ctx, api_version=None): @@ -23,6 +30,33 @@ def cf_acr_registries(cli_ctx, *_): return get_acr_service_client(cli_ctx).registries +def cf_acr_import(cli_ctx, command_args): + from azure.cli.core.azclierror import ArgumentUsageError, ValidationError + from azure.cli.core._profile import Profile + from azure.mgmt.core.tools import is_valid_resource_id, parse_resource_id + + registry_tenant = command_args.pop('registry_tenant', None) + source_registry = command_args.get('source_registry') + + if registry_tenant: + if not is_valid_resource_id(source_registry): + raise ArgumentUsageError(REGISTRY_TENANT_USAGE_ERROR) + + resource_id = parse_resource_id(source_registry) + if (resource_id.get('namespace', '').lower() != 'microsoft.containerregistry' or + resource_id.get('type', '').lower() != 'registries'): + raise ArgumentUsageError(REGISTRY_TENANT_USAGE_ERROR) + + profile = Profile(cli_ctx=cli_ctx) + account = profile.get_subscription() + user = account.get('user', {}) + if user.get('name') in ('systemAssignedIdentity', 'userAssignedIdentity') or user.get('cloudShellID'): + raise ValidationError(REGISTRY_TENANT_AUTH_ERROR) + + aux_tenants = [registry_tenant] if registry_tenant else None + return get_acr_service_client(cli_ctx, aux_tenants=aux_tenants).registries + + def cf_acr_cache(cli_ctx, *_): return get_acr_service_client(cli_ctx).cache_rules diff --git a/src/azure-cli/azure/cli/command_modules/acr/_help.py b/src/azure-cli/azure/cli/command_modules/acr/_help.py index 49a30ab7c88..9b94fee19b7 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/_help.py +++ b/src/azure-cli/azure/cli/command_modules/acr/_help.py @@ -337,6 +337,11 @@ text: | az acr import -n myregistry --source sourcerepository:sourcetag -t targetrepository:targettag \\ -r /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sourceResourceGroup/providers/Microsoft.ContainerRegistry/registries/sourceRegistry + - name: Import an image from an Azure container registry in a different tenant. + text: | + az acr import -n myregistry --source sourcerepository:sourcetag -t targetrepository:targettag \\ + -r /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/sourceResourceGroup/providers/Microsoft.ContainerRegistry/registries/sourceRegistry \\ + --registry-tenant 00000000-0000-0000-0000-000000000000 - name: Import an image without waiting for successful completion. Failures during import will not be reflected. Run `az acr repository show-tags` to confirm that import succeeded. text: > az acr import -n myregistry --source sourceregistry.azurecr.io/sourcerepository:sourcetag --no-wait diff --git a/src/azure-cli/azure/cli/command_modules/acr/_params.py b/src/azure-cli/azure/cli/command_modules/acr/_params.py index baf7843862b..ff997649e9f 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/_params.py +++ b/src/azure-cli/azure/cli/command_modules/acr/_params.py @@ -151,6 +151,9 @@ def load_arguments(self, _): # pylint: disable=too-many-statements with self.argument_context('acr import') as c: c.argument('source_image', options_list=['--source'], help="Source image name or fully qualified source containing the registry login server. If `--registry` is used, `--source` will always be interpreted as a source image, even if it contains the login server.") c.argument('source_registry', options_list=['--registry', '-r'], help='The source Azure container registry. This can be name, login server or resource ID of the source registry.') + c.argument('registry_tenant', options_list=['--registry-tenant'], + help='The tenant ID of the source Azure container registry. ' + 'Only supported when --registry is a container registry resource ID.') c.argument('source_registry_username', options_list=['--username', '-u'], help='The username of source container registry') c.argument('source_registry_password', options_list=['--password', '-p'], help='The password of source container registry') c.argument('target_tags', options_list=['--image', '-t'], help="The name and tag of the image using the format: '-t repo/image:tag'. Multiple tags are supported by passing -t multiple times.", action='append') diff --git a/src/azure-cli/azure/cli/command_modules/acr/commands.py b/src/azure-cli/azure/cli/command_modules/acr/commands.py index 4148c2d5f51..71b09d75f4d 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/commands.py +++ b/src/azure-cli/azure/cli/command_modules/acr/commands.py @@ -33,6 +33,7 @@ ) from ._client_factory import ( cf_acr_registries, + cf_acr_import, cf_acr_replications, cf_acr_webhooks, cf_acr_tasks, @@ -69,7 +70,7 @@ def load_command_table(self, _): acr_import_util = CliCommandType( operations_tmpl='azure.cli.command_modules.acr.import#{}', - client_factory=cf_acr_registries + client_factory=cf_acr_import ) acr_policy_util = CliCommandType( diff --git a/src/azure-cli/azure/cli/command_modules/acr/import.py b/src/azure-cli/azure/cli/command_modules/acr/import.py index 710515a64e6..eb663c99896 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/import.py +++ b/src/azure-cli/azure/cli/command_modules/acr/import.py @@ -40,6 +40,7 @@ def acr_import(cmd, # pylint: disable=too-many-locals resource_group_name=None, repository=None, force=False, + registry_tenant=None, # pylint: disable=unused-argument no_wait=False): if source_registry_username and not source_registry_password: diff --git a/src/azure-cli/azure/cli/command_modules/acr/tests/latest/test_acr_client_factory.py b/src/azure-cli/azure/cli/command_modules/acr/tests/latest/test_acr_client_factory.py new file mode 100644 index 00000000000..0e01c4a8499 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/acr/tests/latest/test_acr_client_factory.py @@ -0,0 +1,135 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest +from unittest import mock + +from azure.cli.core.azclierror import ArgumentUsageError, ValidationError +from azure.cli.command_modules.acr import _client_factory + + +class AcrClientFactoryTests(unittest.TestCase): + + @mock.patch.object(_client_factory, 'get_mgmt_service_client') + def test_acr_service_client_uses_auxiliary_tenants(self, get_mgmt_service_client): + _client_factory.get_acr_service_client( + mock.sentinel.cli_ctx, aux_tenants=['source-tenant']) + + _, kwargs = get_mgmt_service_client.call_args + self.assertEqual(kwargs['aux_tenants'], ['source-tenant']) + + @mock.patch.object(_client_factory, 'get_acr_service_client') + @mock.patch('azure.cli.core._profile.Profile') + def test_import_client_uses_registry_tenant(self, profile, get_acr_service_client): + registries = mock.sentinel.registries + get_acr_service_client.return_value.registries = registries + profile.return_value.get_subscription.return_value = {'user': {'type': 'user'}} + command_args = { + 'source_registry': ( + '/subscriptions/source-subscription/resourceGroups/source-rg/providers/' + 'Microsoft.ContainerRegistry/registries/source-registry'), + 'registry_tenant': 'source-tenant' + } + + result = _client_factory.cf_acr_import(mock.sentinel.cli_ctx, command_args) + + self.assertIs(result, registries) + get_acr_service_client.assert_called_once_with( + mock.sentinel.cli_ctx, aux_tenants=['source-tenant']) + self.assertNotIn('registry_tenant', command_args) + + @mock.patch.object(_client_factory, 'get_acr_service_client') + def test_import_client_rejects_registry_name_with_registry_tenant(self, get_acr_service_client): + command_args = { + 'source_registry': 'source-registry', + 'registry_tenant': 'source-tenant' + } + + with self.assertRaisesRegex( + ArgumentUsageError, + '--registry-tenant can only be used when --registry is a container registry resource ID'): + _client_factory.cf_acr_import(mock.sentinel.cli_ctx, command_args) + + get_acr_service_client.assert_not_called() + + @mock.patch.object(_client_factory, 'get_acr_service_client') + def test_import_client_rejects_missing_registry_with_registry_tenant(self, get_acr_service_client): + command_args = {'registry_tenant': 'source-tenant'} + + with self.assertRaisesRegex( + ArgumentUsageError, + '--registry-tenant can only be used when --registry is a container registry resource ID'): + _client_factory.cf_acr_import(mock.sentinel.cli_ctx, command_args) + + get_acr_service_client.assert_not_called() + + @mock.patch.object(_client_factory, 'get_acr_service_client') + def test_import_client_rejects_non_registry_resource_id(self, get_acr_service_client): + command_args = { + 'source_registry': ( + '/subscriptions/source-subscription/resourceGroups/source-rg/providers/' + 'Microsoft.Storage/storageAccounts/source-storage'), + 'registry_tenant': 'source-tenant' + } + + with self.assertRaisesRegex( + ArgumentUsageError, + '--registry-tenant can only be used when --registry is a container registry resource ID'): + _client_factory.cf_acr_import(mock.sentinel.cli_ctx, command_args) + + get_acr_service_client.assert_not_called() + + @mock.patch.object(_client_factory, 'get_acr_service_client') + @mock.patch('azure.cli.core._profile.Profile') + def test_import_client_rejects_managed_identity(self, profile, get_acr_service_client): + profile.return_value.get_subscription.return_value = { + 'user': {'name': 'systemAssignedIdentity'} + } + command_args = { + 'source_registry': ( + '/subscriptions/source-subscription/resourceGroups/source-rg/providers/' + 'Microsoft.ContainerRegistry/registries/source-registry'), + 'registry_tenant': 'source-tenant' + } + + with self.assertRaisesRegex( + ValidationError, + '--registry-tenant is not supported when signed in with managed identity'): + _client_factory.cf_acr_import(mock.sentinel.cli_ctx, command_args) + + get_acr_service_client.assert_not_called() + + @mock.patch.object(_client_factory, 'get_acr_service_client') + @mock.patch('azure.cli.core._profile.Profile') + def test_import_client_rejects_cloud_shell(self, profile, get_acr_service_client): + profile.return_value.get_subscription.return_value = {'user': {'cloudShellID': True}} + command_args = { + 'source_registry': ( + '/subscriptions/source-subscription/resourceGroups/source-rg/providers/' + 'Microsoft.ContainerRegistry/registries/source-registry'), + 'registry_tenant': 'source-tenant' + } + + with self.assertRaisesRegex( + ValidationError, + '--registry-tenant is not supported when signed in with managed identity or Cloud Shell'): + _client_factory.cf_acr_import(mock.sentinel.cli_ctx, command_args) + + get_acr_service_client.assert_not_called() + + @mock.patch.object(_client_factory, 'get_acr_service_client') + def test_import_client_without_registry_tenant(self, get_acr_service_client): + registries = mock.sentinel.registries + get_acr_service_client.return_value.registries = registries + command_args = {'source_registry': 'source-registry'} + + result = _client_factory.cf_acr_import(mock.sentinel.cli_ctx, command_args) + + self.assertIs(result, registries) + get_acr_service_client.assert_called_once_with(mock.sentinel.cli_ctx, aux_tenants=None) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/azure-cli/azure/cli/command_modules/acr/tests/latest/test_acr_commands.py b/src/azure-cli/azure/cli/command_modules/acr/tests/latest/test_acr_commands.py index b16d9e88567..e0b84f0a197 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/tests/latest/test_acr_commands.py +++ b/src/azure-cli/azure/cli/command_modules/acr/tests/latest/test_acr_commands.py @@ -420,6 +420,12 @@ def test_acr_import_no_wait(self, resource_group): # import image using no-wait self.cmd('acr import -n {source_registry_name} -r {resource_id} --source {source_image} --no-wait') + # --registry-tenant requires --registry to be a source registry resource ID + self.cmd( + 'acr import -n {source_registry_name} -r {source_registry_name} --source {source_image} ' + '--registry-tenant 00000000-0000-0000-0000-000000000000', + expect_failure=True) + @AllowLargeResponse() @ResourceGroupPreparer() @record_only() @@ -1138,4 +1144,3 @@ def test_acr_create_with_domain_name_label_scope_name_in_use(self, resource_grou with self.assertRaises(Exception) as ex: self.cmd('acr create -n {registry_name} -g {rg2} -l {rg_loc} --sku {sku} --dnl-scope resourcegroupreuse', checks=['code','RegistryNameAlreadyInUse']) -