Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 65 additions & 49 deletions src/azure-cli/azure/cli/command_modules/acr/_docker_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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('/')

Expand Down Expand Up @@ -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'])
Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand All @@ -207,34 +226,34 @@ def _get_aad_token(cli_ctx,
token_params,
login_server,
only_refresh_token,
package_type,
repository,
artifact_repository,
permission,
is_diagnostics_context)


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):
"""Decides and obtains credentials for a registry using username and password.
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'
"""

if is_login_context:
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
Expand All @@ -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', '', '', ''))
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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))

Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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)


Expand Down Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion src/azure-cli/azure/cli/command_modules/acr/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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'
Expand Down
9 changes: 9 additions & 0 deletions src/azure-cli/azure/cli/command_modules/acr/_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
8 changes: 8 additions & 0 deletions src/azure-cli/azure/cli/command_modules/acr/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down
32 changes: 31 additions & 1 deletion src/azure-cli/azure/cli/command_modules/acr/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)

Expand Down
Loading