diff --git a/src/azure-cli/azure/cli/command_modules/acr/_docker_utils.py b/src/azure-cli/azure/cli/command_modules/acr/_docker_utils.py index 001245becfc..fdf9f80a30f 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/_docker_utils.py +++ b/src/azure-cli/azure/cli/command_modules/acr/_docker_utils.py @@ -42,6 +42,12 @@ ALLOWS_BASIC_AUTH = "allows_basic_auth" +class PackageType(Enum): + OCI = 'oci' + ARTIFACT = 'artifact' + PYPI = 'pypi' + + class RepoAccessTokenPermission(Enum): METADATA_READ = 'metadata_read' METADATA_WRITE = 'metadata_write' @@ -58,27 +64,31 @@ class HelmAccessTokenPermission(Enum): DELETE_PULL = 'delete,pull' +class PackageAccessTokenPermission(Enum): + METADATA_READ = 'metadata_read' + PULL = 'pull' + PUSH = 'push' + DELETE = 'delete' + + def _handle_challenge_phase(login_server, - repository, - artifact_repository, + package_type, permission, is_aad_token=True, is_diagnostics_context=False): - if repository and artifact_repository: - raise ValueError("Only one of repository and artifact_repository can be provided.") - - repo_permissions = {permission.value for permission in RepoAccessTokenPermission} - if repository and permission not in repo_permissions: - raise ValueError( - "Permission is required for a repository. Allowed access token permission: {}" - .format(repo_permissions)) - - helm_permissions = {permission.value for permission in HelmAccessTokenPermission} - if artifact_repository and permission not in helm_permissions: - raise ValueError( - "Permission is required for an artifact_repository. Allowed access token permission: {}" - .format(helm_permissions)) + if package_type is PackageType.OCI: + repo_permissions = {permission.value for permission in RepoAccessTokenPermission} + if permission not in repo_permissions: + raise ValueError( + "Permission is required for a repository. Allowed access token permission: {}" + .format(repo_permissions)) + elif package_type is PackageType.ARTIFACT: + helm_permissions = {permission.value for permission in HelmAccessTokenPermission} + if permission not in helm_permissions: + raise ValueError( + "Permission is required for an artifact repository. Allowed access token permission: {}" + .format(helm_permissions)) login_server = login_server.rstrip('/') @@ -110,12 +120,27 @@ def _handle_challenge_phase(login_server, return token_params +def _get_scope(package_type, + repository=None, + permission=None): + if package_type is PackageType.OCI: + # catalog only has * as permission, even for a read operation + return 'repository:{}:{}'.format(repository, permission) if repository else 'registry:catalog:*' + elif package_type is PackageType.ARTIFACT: + return 'artifact-repository:{}:{}'.format(repository, permission) + elif package_type is PackageType.PYPI: + return 'pypi-package:{}:{}'.format(repository, permission) if repository else 'pypi-registry:catalog:metadata_read' + else: + allowed_package_types = {package_type.value for package_type in PackageType} + raise ValueError('Invalid package type {}. Allowed package types: {}'.format(package_type, allowed_package_types)) + + def _get_aad_token_after_challenge(cli_ctx, token_params, login_server, only_refresh_token, + package_type, repository, - artifact_repository, permission, is_diagnostics_context): authurl = urlparse(token_params['realm']) @@ -152,13 +177,7 @@ def _get_aad_token_after_challenge(cli_ctx, authhost = urlunparse((authurl[0], authurl[1], '/oauth2/token', '', '', '')) - if repository: - scope = 'repository:{}:{}'.format(repository, permission) - elif artifact_repository: - scope = 'artifact-repository:{}:{}'.format(artifact_repository, permission) - else: - # catalog only has * as permission, even for a read operation - scope = 'registry:catalog:*' + scope = _get_scope(package_type, repository, permission) content = { 'grant_type': 'refresh_token', @@ -182,19 +201,19 @@ def _get_aad_token_after_challenge(cli_ctx, def _get_aad_token(cli_ctx, login_server, only_refresh_token, + package_type=None, repository=None, - artifact_repository=None, permission=None, is_diagnostics_context=False): """Obtains refresh and access tokens for an AAD-enabled registry. :param str login_server: The registry login server URL to log in to :param bool only_refresh_token: Whether to ask for only refresh token, or for both refresh and access tokens + :param PackageType package_type: Package type for which the access token is requested :param str repository: Repository for which the access token is requested - :param str artifact_repository: Artifact repository for which the access token is requested :param str permission: The requested permission on the repository, '*' or 'pull' """ token_params = _handle_challenge_phase( - login_server, repository, artifact_repository, permission, True, is_diagnostics_context + login_server, package_type, permission, True, is_diagnostics_context ) from ._errors import ErrorClass @@ -207,8 +226,8 @@ def _get_aad_token(cli_ctx, token_params, login_server, only_refresh_token, + package_type, repository, - artifact_repository, permission, is_diagnostics_context) @@ -216,8 +235,8 @@ def _get_aad_token(cli_ctx, def _get_token_with_username_and_password(login_server, username, password, + package_type=None, repository=None, - artifact_repository=None, permission=None, is_login_context=False, is_diagnostics_context=False): @@ -225,8 +244,8 @@ def _get_token_with_username_and_password(login_server, To be used for scoped access credentials. :param str login_server: The registry login server URL to log in to :param bool only_refresh_token: Whether to ask for only refresh token, or for both refresh and access tokens + :param PackageType package_type: Package type for which the access token is requested :param str repository: Repository for which the access token is requested - :param str artifact_repository: Artifact repository for which the access token is requested :param str permission: The requested permission on the repository, '*' or 'pull' """ @@ -234,7 +253,7 @@ def _get_token_with_username_and_password(login_server, return username, password token_params = _handle_challenge_phase( - login_server, repository, artifact_repository, permission, False, is_diagnostics_context + login_server, package_type, permission, False, is_diagnostics_context ) from ._errors import ErrorClass @@ -246,13 +265,7 @@ def _get_token_with_username_and_password(login_server, if ALLOWS_BASIC_AUTH in token_params: return username, password - if repository: - scope = 'repository:{}:{}'.format(repository, permission) - elif artifact_repository: - scope = 'artifact-repository:{}:{}'.format(artifact_repository, permission) - else: - # catalog only has * as permission, even for a read operation - scope = 'registry:catalog:*' + scope = _get_scope(package_type, repository, permission) authurl = urlparse(token_params['realm']) authhost = urlunparse((authurl[0], authurl[1], '/oauth2/token', '', '', '')) @@ -286,8 +299,8 @@ def _get_credentials(cmd, # pylint: disable=too-many-statements username, password, only_refresh_token, + package_type=None, repository=None, - artifact_repository=None, permission=None, is_login_context=False): """Try to get AAD authorization tokens or admin user credentials. @@ -296,8 +309,8 @@ def _get_credentials(cmd, # pylint: disable=too-many-statements :param str username: The username used to log into the container registry :param str password: The password used to log into the container registry :param bool only_refresh_token: Whether to ask for only refresh token, or for both refresh and access tokens + :param PackageType package_type: Package type for which the access token is requested :param str repository: Repository for which the access token is requested - :param str artifact_repository: Artifact repository for which the access token is requested :param str permission: The requested permission on the repository, '*' or 'pull' """ # Raise an error if password is specified but username isn't @@ -355,7 +368,7 @@ def _get_credentials(cmd, # pylint: disable=too-many-statements raise CLIError('Please specify both username and password in non-interactive mode.') username, password = _get_token_with_username_and_password( - login_server, username, password, repository, artifact_repository, permission, is_login_context + login_server, username, password, package_type, repository, permission, is_login_context ) return login_server, username, password @@ -364,7 +377,7 @@ def _get_credentials(cmd, # pylint: disable=too-many-statements logger.info("Attempting to retrieve AAD refresh token...") try: return login_server, EMPTY_GUID, _get_aad_token( - cli_ctx, login_server, only_refresh_token, repository, artifact_repository, permission) + cli_ctx, login_server, only_refresh_token, package_type, repository, permission) except CLIError as e: logger.warning("%s: %s", AAD_TOKEN_BASE_ERROR_MESSAGE, str(e)) @@ -387,7 +400,7 @@ def _get_credentials(cmd, # pylint: disable=too-many-statements username = prompt('Username: ') password = prompt_pass(msg='Password: ') username, password = _get_token_with_username_and_password( - login_server, username, password, repository, artifact_repository, permission, is_login_context + login_server, username, password, package_type, repository, permission, is_login_context ) return login_server, username, password except NoTTYException: @@ -422,15 +435,15 @@ def get_access_credentials(cmd, tenant_suffix=None, username=None, password=None, + package_type=PackageType.OCI, repository=None, - artifact_repository=None, permission=None): """Try to get AAD authorization tokens or admin user credentials to access a registry. :param str registry_name: The name of container registry :param str username: The username used to log into the container registry :param str password: The password used to log into the container registry + :param PackageType package_type: Package type for which the access token is requested :param str repository: Repository for which the access token is requested - :param str artifact_repository: Artifact repository for which the access token is requested :param str permission: The requested permission on the repository """ return _get_credentials(cmd, @@ -439,8 +452,8 @@ def get_access_credentials(cmd, username, password, only_refresh_token=False, + package_type=package_type, repository=repository, - artifact_repository=artifact_repository, permission=permission) @@ -539,9 +552,12 @@ def request_data_from_registry(http_method, log_registry_response(response) if response.status_code == 200: - result = response.json()[result_index] if result_index else response.json() - next_link = response.headers['link'] if 'link' in response.headers else None - return result, next_link + try: + result = response.json()[result_index] if result_index else response.json() + next_link = response.headers['link'] if 'link' in response.headers else None + return result, next_link + except ValueError: + return response.text, None if response.status_code == 201 or response.status_code == 202: result = None try: 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 9c5b079e34c..0732ebf0b54 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/_params.py +++ b/src/azure-cli/azure/cli/command_modules/acr/_params.py @@ -36,7 +36,8 @@ validate_set_secret, validate_retention_days, validate_registry_name, - validate_expiration_time + validate_expiration_time, + validate_package_type ) from .scope_map import ScopeMapActions @@ -75,6 +76,9 @@ def load_arguments(self, _): # pylint: disable=too-many-statements # Overwrite default shorthand of cmd to make availability for acr usage c.argument('cmd', options_list=['--__cmd__']) c.argument('cmd_value', help="Commands to execute.", options_list=['--cmd']) + c.argument('package_type', help='The package type.', arg_type=get_enum_type(['pypi']), validator=validate_package_type) + c.argument('package_name', help='The package name.') + c.argument('permissions', nargs='+', help='Space-separated list of permissions.', arg_type=get_enum_type(['pull', 'push', 'delete'])) for scope in ['acr create', 'acr update']: with self.argument_context(scope, arg_group='Network Rule') as c: @@ -381,6 +385,9 @@ def load_arguments(self, _): # pylint: disable=too-many-statements c.argument('key_encryption_key', help="key vault key uri") c.argument('identity', help="client id of managed identity, resource name or id of user assigned identity. Use '[system]' to refer to the system assigned identity") + with self.argument_context('acr package upload') as c: + c.positional('file_path', help='The package file path') + def _get_helm_default_install_location(): exe_name = 'helm' diff --git a/src/azure-cli/azure/cli/command_modules/acr/_validators.py b/src/azure-cli/azure/cli/command_modules/acr/_validators.py index e9ce4ce9e62..53236aa789a 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/_validators.py +++ b/src/azure-cli/azure/cli/command_modules/acr/_validators.py @@ -112,3 +112,12 @@ def validate_expiration_time(namespace): except ValueError: raise CLIError("Input '{}' is not valid datetime. Valid example: 2025-12-31T12:59:59Z".format( namespace.expiration)) + + +def validate_package_type(namespace): + if namespace.package_type: + from ._docker_utils import PackageType + if namespace.package_type == PackageType.PYPI.value: + namespace.package_type = PackageType.PYPI + else: + raise CLIError("Invalid package type '{}'".format(namespace.package_type)) 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 26ee3adb228..d3fe2d60147 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/commands.py +++ b/src/azure-cli/azure/cli/command_modules/acr/commands.py @@ -160,6 +160,10 @@ def load_command_table(self, _): # pylint: disable=too-many-statements client_factory=cf_acr_private_endpoint_connections ) + acr_package_util = CliCommandType( + operations_tmpl='azure.cli.command_modules.acr.package#{}' + ) + with self.command_group('acr', acr_custom_util) as g: g.command('check-name', 'acr_check_name', table_transformer=None) g.command('list', 'acr_list') @@ -292,6 +296,10 @@ def _helm_deprecate_message(self): g.command('repo add', 'acr_helm_repo_add') g.command('install-cli', 'acr_helm_install_cli', is_preview=True) + with self.command_group('acr package', acr_package_util, is_preview=True) as g: + g.command('list', 'acr_package_list') + g.command('delete', 'acr_package_delete') + with self.command_group('acr network-rule', acr_network_rule_util) as g: g.command('list', 'acr_network_rule_list') g.command('add', 'acr_network_rule_add') diff --git a/src/azure-cli/azure/cli/command_modules/acr/custom.py b/src/azure-cli/azure/cli/command_modules/acr/custom.py index 95f7da45a27..a6a08fb90c9 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/custom.py +++ b/src/azure-cli/azure/cli/command_modules/acr/custom.py @@ -192,11 +192,34 @@ def acr_login(cmd, tenant_suffix=None, username=None, password=None, - expose_token=False): + expose_token=False, + package_type=None, + package_name=None, + permissions=None): if expose_token: if username or password: raise CLIError("`--expose-token` cannot be combined with `--username` or `--password`.") + if package_type: + if not package_name or not permissions: + raise CLIError("`--package-name` and `--permissions` are required with `--package-type`.") + + from ._docker_utils import get_access_credentials, PackageAccessTokenPermission + login_server, username, password = get_access_credentials( + cmd=cmd, + registry_name=registry_name, + tenant_suffix=tenant_suffix, + package_type=package_type, + repository=package_name, + # always add metadata read + permission='{},{}'.format(','.join(permissions), PackageAccessTokenPermission.METADATA_READ.value)) + + return { + "endpoint": '{}/pkg/v1/pypi'.format(login_server), # TODO: get the endpoint from RP + "username": username, + "password": password + } + login_server, _, password = get_login_credentials( cmd=cmd, registry_name=registry_name, @@ -214,6 +237,13 @@ def acr_login(cmd, return token_info + if package_type: + raise CLIError("`--package-type` must be used with `--expose-token`.") + if package_name: + raise CLIError("`--package-name` must be used with `--expose-token`.") + if permissions: + raise CLIError("`--permissions` must be used with `--expose-token`.") + tips = "You may want to use 'az acr login -n {} --expose-token' to get an access token, " \ "which does not require Docker to be installed.".format(registry_name) diff --git a/src/azure-cli/azure/cli/command_modules/acr/helm.py b/src/azure-cli/azure/cli/command_modules/acr/helm.py index fd6ff3ab20c..1db7e553764 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/helm.py +++ b/src/azure-cli/azure/cli/command_modules/acr/helm.py @@ -18,6 +18,7 @@ get_access_credentials, request_data_from_registry, RegistryException, + PackageType, HelmAccessTokenPermission ) @@ -38,7 +39,8 @@ def acr_helm_list(cmd, tenant_suffix=tenant_suffix, username=username, password=password, - artifact_repository=repository, + package_type=PackageType.ARTIFACT, + repository=repository, permission=HelmAccessTokenPermission.PULL.value) return request_data_from_registry( @@ -64,7 +66,8 @@ def acr_helm_show(cmd, tenant_suffix=tenant_suffix, username=username, password=password, - artifact_repository=repository, + package_type=PackageType.ARTIFACT, + repository=repository, permission=HelmAccessTokenPermission.PULL.value) return request_data_from_registry( @@ -99,7 +102,8 @@ def acr_helm_delete(cmd, tenant_suffix=tenant_suffix, username=username, password=password, - artifact_repository=repository, + package_type=PackageType.ARTIFACT, + repository=repository, permission=HelmAccessTokenPermission.DELETE.value) return request_data_from_registry( @@ -130,7 +134,8 @@ def acr_helm_push(cmd, tenant_suffix=tenant_suffix, username=username, password=password, - artifact_repository=repository, + package_type=PackageType.ARTIFACT, + repository=repository, permission=HelmAccessTokenPermission.PUSH_PULL.value) path = _get_blobs_path(repository, basename(chart_package)) @@ -172,7 +177,8 @@ def acr_helm_repo_add(cmd, tenant_suffix=tenant_suffix, username=username, password=password, - artifact_repository=repository, + package_type=PackageType.ARTIFACT, + repository=repository, permission=HelmAccessTokenPermission.PULL.value) from subprocess import Popen diff --git a/src/azure-cli/azure/cli/command_modules/acr/package.py b/src/azure-cli/azure/cli/command_modules/acr/package.py new file mode 100644 index 00000000000..bec5b28c0f7 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/acr/package.py @@ -0,0 +1,94 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import platform +from six.moves.urllib.request import urlopen # pylint: disable=import-error + +from knack.util import CLIError +from knack.log import get_logger + +from azure.cli.core.util import in_cloud_console + +from ._utils import user_confirmation + +from ._docker_utils import ( + get_access_credentials, + request_data_from_registry, + PackageAccessTokenPermission, + PackageType +) + + +logger = get_logger(__name__) + + +def acr_package_list(cmd, + registry_name, + package_type, + package_name=None, + tenant_suffix=None, + username=None, + password=None): + login_server, username, password = get_access_credentials( + cmd=cmd, + registry_name=registry_name, + tenant_suffix=tenant_suffix, + username=username, + password=password, + package_type=package_type, + repository=package_name, + permission=PackageAccessTokenPermission.METADATA_READ.value) + + html = request_data_from_registry( + http_method='get', + login_server=login_server, + path=_get_package_path(package_type, package_name), + username=username, + password=password)[0] + + print(html) + + +def acr_package_delete(cmd, + registry_name, + package_type, + package_name, + version, + tenant_suffix=None, + username=None, + password=None, + yes=False): + message = "This operation will delete the package '{}' version '{}'".format(package_name, version) + user_confirmation("{}.\nAre you sure you want to continue?".format(message), yes) + + login_server, username, password = get_access_credentials( + cmd=cmd, + registry_name=registry_name, + tenant_suffix=tenant_suffix, + username=username, + password=password, + package_type=package_type, + repository=package_name, + permission=PackageAccessTokenPermission.DELETE.value) + + return request_data_from_registry( + http_method='delete', + login_server=login_server, + path=_get_package_path(package_type, package_name, version), + username=username, + password=password)[0] + + +def _get_package_path(package_type, package_name, version=None): + if package_type is PackageType.PYPI: + if not package_name: + return '/pkg/v1/pypi' + if not version: + return '/pkg/v1/pypi/{}'.format(package_name) + + return '/pkg/v1/pypi/{}/{}'.format(package_name, version) + + raise ValueError("Unknown package type {}".format(package_type))