diff --git a/linter_exclusions.yml b/linter_exclusions.yml index c27e802c1ae..9943b4d0634 100644 --- a/linter_exclusions.yml +++ b/linter_exclusions.yml @@ -28,6 +28,41 @@ acr helm show: chart: rule_exclusions: - no_positional_parameters +acr manifest delete: + parameters: + manifest_id: + rule_exclusions: + - no_positional_parameters +acr manifest list: + parameters: + repo_id: + rule_exclusions: + - no_positional_parameters +acr manifest list-referrers: + parameters: + manifest_id: + rule_exclusions: + - no_positional_parameters +acr manifest metadata list: + parameters: + repo_id: + rule_exclusions: + - no_positional_parameters +acr manifest metadata show: + parameters: + manifest_id: + rule_exclusions: + - no_positional_parameters +acr manifest metadata update: + parameters: + manifest_id: + rule_exclusions: + - no_positional_parameters +acr manifest show: + parameters: + manifest_id: + rule_exclusions: + - no_positional_parameters acr pack build: parameters: source_location: @@ -261,7 +296,7 @@ aks create: - option_length_too_long enable_encryption_at_host: rule_exclusions: - - option_length_too_long + - option_length_too_long assign_kubelet_identity: rule_exclusions: - option_length_too_long 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..85c583ff88b 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 @@ -48,6 +48,8 @@ class RepoAccessTokenPermission(Enum): DELETE = 'delete' META_WRITE_META_READ = '{},{}'.format(METADATA_WRITE, METADATA_READ) DELETE_META_READ = '{},{}'.format(DELETE, METADATA_READ) + PULL = 'pull' + PULL_META_READ = '{},{}'.format(PULL, METADATA_READ) class HelmAccessTokenPermission(Enum): @@ -484,6 +486,17 @@ def get_authorization_header(username, password): return {'Authorization': auth} +def get_manifest_authorization_header(username, password): + if username == EMPTY_GUID: + auth = _get_bearer_auth_str(password) + else: + auth = _get_basic_auth_str(username, password) + return {'Authorization': auth, + 'Accept': '*/*, application/vnd.cncf.oras.artifact.manifest.v1+json' + ', application/vnd.oci.image.manifest.v1+json'} + + +# pylint: disable=too-many-statements def request_data_from_registry(http_method, login_server, path, @@ -493,6 +506,8 @@ def request_data_from_registry(http_method, json_payload=None, file_payload=None, params=None, + manifest_headers=False, + raw=False, retry_times=3, retry_interval=5, timeout=300): @@ -509,7 +524,11 @@ def request_data_from_registry(http_method, raise ValueError("Non-empty payload is required for http method: {}".format(http_method)) url = 'https://{}{}'.format(login_server, path) - headers = get_authorization_header(username, password) + + if manifest_headers: + headers = get_manifest_authorization_header(username, password) + else: + headers = get_authorization_header(username, password) for i in range(0, retry_times): errorMessage = None @@ -538,6 +557,8 @@ def request_data_from_registry(http_method, log_registry_response(response) + if manifest_headers and raw and response.status_code == 200: + return response.content.decode('utf-8'), None 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 diff --git a/src/azure-cli/azure/cli/command_modules/acr/_format.py b/src/azure-cli/azure/cli/command_modules/acr/_format.py index f24e53786aa..cdec7ea5f28 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/_format.py +++ b/src/azure-cli/azure/cli/command_modules/acr/_format.py @@ -129,6 +129,29 @@ def connected_registry_list_output_format(result): return _output_format(result_list_format, _connected_registry_list_format_group) +def list_referrers_output_format(result): + manifests = [] + for manifest in result['references']: + manifests.append(OrderedDict([ + ('Digest', _get_value(manifest, 'digest')), + ('ArtifactType', _get_value(manifest, 'artifactType')), + ('MediaType', _get_value(manifest, 'mediaType')), + ('Size', _get_value(manifest, 'size')) + ])) + return manifests + + +def manifest_output_format(result): + manifests = [] + for manifest in result: + manifests.append(OrderedDict([ + ('MediaType', _get_value(manifest, 'mediaType')), + ('ArtifactType', _get_value(manifest, 'artifactType')), + ('SubjectDigest', _get_value(manifest, 'subject', 'digest')) + ])) + return manifests + + def _recursive_format_list_acr_childs(family_tree, connected_registry_id): connected_registry = family_tree[connected_registry_id] childs = connected_registry['childs'] 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 1ba15104819..c9695a2d38e 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/_help.py +++ b/src/azure-cli/azure/cli/command_modules/acr/_help.py @@ -492,6 +492,98 @@ text: az acr repository update -n MyRegistry --image hello-world@sha256:abc123 --write-enabled false """ +helps['acr manifest'] = """ +type: group +short-summary: Manage artifact manifests in Azure Container Registries. +""" + +helps['acr manifest show'] = """ +type: command +short-summary: Get a manifest in an Azure Container Registry. +examples: + - name: Get the manifest of the artifact 'hello-world:latest'. + text: az acr manifest show -r MyRegistry -n hello-world:latest + - name: Get the manifest of the artifact 'hello-world:latest'. + text: az acr manifest show MyRegistry.azurecr.io/hello-world:latest + - name: Get the manifest of the artifact referenced by digest 'hello-world@sha256:abc123'. + text: az acr manifest show -r MyRegistry -n hello-world@sha256:abc123 + - name: Get the raw, unformatted manifest of the artifact 'hello-world:latest'. + text: az acr manifest show -r MyRegistry -n hello-world:latest --raw +""" + +helps['acr manifest list'] = """ +type: command +short-summary: List the manifests in a repository in an Azure Container Registry. +examples: + - name: List the manifests of the repository 'hello-world'. + text: az acr manifest list -r MyRegistry -n hello-world + - name: List the manifests of the repository 'hello-world'. + text: az acr manifest list MyRegistry.azurecr.io/hello-world +""" + +helps['acr manifest delete'] = """ +type: command +short-summary: Delete a manifest in an Azure Container Registry. +examples: + - name: Delete the manifest of the artifact 'hello-world:latest'. + text: az acr manifest delete -r MyRegistry -n hello-world:latest + - name: Delete the manifest of the artifact 'hello-world:latest'. + text: az acr manifest delete MyRegistry.azurecr.io/hello-world:latest + - name: Delete the manifest of the artifact referenced by digest 'hello-world@sha256:abc123'. + text: az acr manifest delete -r MyRegistry -n hello-world@sha256:abc123 +""" + +helps['acr manifest list-referrers'] = """ +type: command +short-summary: List the ORAS referrers to a manifest in an Azure Container Registry. +examples: + - name: List the referrers to the manifest of the artifact 'hello-world:latest'. + text: az acr manifest list-referrers -r MyRegistry -n hello-world:latest + - name: List the referrers to the manifest of the artifact 'hello-world:latest'. + text: az acr manifest list-referrers MyRegistry.azurecr.io/hello-world:latest + - name: List the referrers to the manifest of the artifact referenced by digest 'hello-world@sha256:abc123'. + text: az acr manifest list-referrers -r MyRegistry -n hello-world@sha256:abc123 +""" + +helps['acr manifest metadata'] = """ +type: group +short-summary: Manage artifact manifest metadata in Azure Container Registries. +""" + +helps['acr manifest metadata show'] = """ +type: command +short-summary: Get the metadata of an artifact in an Azure Container Registry. +examples: + - name: Get the metadata of the tag 'hello-world:latest'. + text: az acr manifest metadata show -r MyRegistry -n hello-world:latest + - name: Get the metadata of the tag 'hello-world:latest'. + text: az acr manifest metadata show MyRegistry.azurecr.io/hello-world:latest + - name: Get the metadata of the manifest referenced by digest 'hello-world@sha256:abc123'. + text: az acr manifest metadata show -r MyRegistry -n hello-world@sha256:abc123 +""" + +helps['acr manifest metadata list'] = """ +type: command +short-summary: List the metadata of the manifests in a repository in an Azure Container Registry. +examples: + - name: List the metadata of the manifests in the repository 'hello-world'. + text: az acr manifest metadata list -r MyRegistry -n hello-world + - name: List the metadata of the manifests in the repository 'hello-world'. + text: az acr manifest metadata list MyRegistry.azurecr.io/hello-world +""" + +helps['acr manifest metadata update'] = """ +type: command +short-summary: Update the manifest metadata of an artifact in an Azure Container Registry. +examples: + - name: Update the metadata of the tag 'hello-world:latest'. + text: az acr manifest metadata update -r MyRegistry -n hello-world:latest --write-enabled false + - name: Update the metadata of the tag 'hello-world:latest'. + text: az acr manifest metadata update MyRegistry.azurecr.io/hello-world:latest --write-enabled false + - name: Update the metadata of the artifact referenced by digest 'hello-world@sha256:abc123'. + text: az acr manifest metadata update -r MyRegistry -n hello-world@sha256:abc123 --write-enabled false +""" + helps['acr run'] = """ type: command short-summary: Queues a quick run providing streamed logs for an Azure Container Registry. 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 67ce78f4a88..0b5b6ef9da1 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/_params.py +++ b/src/azure-cli/azure/cli/command_modules/acr/_params.py @@ -36,10 +36,27 @@ validate_set_secret, validate_retention_days, validate_registry_name, - validate_expiration_time + validate_expiration_time, + validate_manifest_id, + validate_repo_id, + validate_repository ) from .scope_map import RepoScopeMapActions, GatewayScopeMapActions +repo_id_type = CLIArgumentType( + nargs='*', + default=None, + validator=validate_repo_id, + help="A fully qualified repository specifier such as 'MyRegistry.azurecr.io/hello-world'." +) + +manifest_id_type = CLIArgumentType( + nargs='*', + default=None, + validator=validate_manifest_id, + help="A fully qualified manifest specifier such as 'MyRegistry.azurecr.io/hello-world:latest'." +) + image_by_tag_or_digest_type = CLIArgumentType( options_list=['--image', '-t'], help="The name of the image. May include a tag in the format 'name:tag' or digest in the format 'name@digest'." @@ -135,6 +152,42 @@ def load_arguments(self, _): # pylint: disable=too-many-statements c.argument('read_enabled', help='Indicates whether read operation is allowed.', arg_type=get_three_state_flag()) c.argument('write_enabled', help='Indicates whether write or delete operation is allowed.', arg_type=get_three_state_flag()) + with self.argument_context('acr manifest') as c: + c.argument('registry_name', options_list=['--registry', '-r'], help='The name of the container registry. You can configure the default registry name using `az configure --defaults acr=`', completer=get_resource_name_completion_list(REGISTRY_RESOURCE_TYPE), configured_default='acr', validator=validate_registry_name) + c.argument('top', type=int, help='Limit the number of items in the results.') + c.argument('orderby', help='Order the items in the results. Default to alphabetical order of names.', arg_type=get_enum_type(['time_asc', 'time_desc'])) + c.argument('delete_enabled', help='Indicate whether delete operation is allowed.', arg_type=get_three_state_flag()) + c.argument('list_enabled', help='Indicate whether this item shows in list operation results.', arg_type=get_three_state_flag()) + c.argument('read_enabled', help='Indicate whether read operation is allowed.', arg_type=get_three_state_flag()) + c.argument('write_enabled', help='Indicate whether write or delete operation is allowed.', arg_type=get_three_state_flag()) + c.argument('repository', help='The name of the repository.', options_list=['--name', '-n'], validator=validate_repository) + c.argument('manifest_spec', help="The name of the artifact. May include a tag in the format 'name:tag' or digest in the format 'name@digest'.", options_list=['--name', '-n']) + + # Positional arguments must be specified on each individual command, they cannot be assigned to a command group + with self.argument_context('acr manifest show') as c: + c.positional('manifest_id', arg_type=manifest_id_type) + c.argument('raw_output', help='Output the raw manifest text with no formatting.', options_list=['--raw'], action='store_true') + + with self.argument_context('acr manifest list') as c: + c.positional('repo_id', arg_type=repo_id_type) + + with self.argument_context('acr manifest delete') as c: + c.positional('manifest_id', arg_type=manifest_id_type) + + with self.argument_context('acr manifest list-referrers') as c: + c.positional('manifest_id', arg_type=manifest_id_type) + c.argument('artifact_type', help='Filter referrers based on artifact type.') + c.argument('recursive', help='Recursively include referrer artifacts.', action='store_true') + + with self.argument_context('acr manifest metadata show') as c: + c.positional('manifest_id', arg_type=manifest_id_type) + + with self.argument_context('acr manifest metadata list') as c: + c.positional('repo_id', arg_type=repo_id_type) + + with self.argument_context('acr manifest metadata update') as c: + c.positional('manifest_id', arg_type=manifest_id_type) + with self.argument_context('acr repository untag') as c: c.argument('image', options_list=['--image', '-t'], help="The name of the image. May include a tag in the format 'name:tag'.") 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..f2e26265f29 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/_validators.py +++ b/src/azure-cli/azure/cli/command_modules/acr/_validators.py @@ -6,6 +6,13 @@ import os from knack.util import CLIError from knack.log import get_logger +from azure.cli.core.azclierror import InvalidArgumentValueError + +BAD_REPO_FQDN = "The positional parameter 'repo_id' must be a fully qualified repository specifier such"\ + " as 'MyRegistry.azurecr.io/hello-world'." +BAD_MANIFEST_FQDN = "The positional parameter 'manifest_id' must be a fully qualified"\ + " manifest specifier such as 'MyRegistry.azurecr.io/hello-world:latest' or"\ + " 'MyRegistry.azurecr.io/hello-world@sha256:abc123'." logger = get_logger(__name__) @@ -112,3 +119,24 @@ 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_repo_id(namespace): + if namespace.repo_id: + repo_id = namespace.repo_id[0] + if '.' not in repo_id or '/' not in repo_id: + raise InvalidArgumentValueError(BAD_REPO_FQDN) + + +def validate_manifest_id(namespace): + if namespace.manifest_id: + manifest_id = namespace.manifest_id[0] + if '.' not in manifest_id or '/' not in manifest_id: + raise InvalidArgumentValueError(BAD_MANIFEST_FQDN) + + +def validate_repository(namespace): + if namespace.repository: + if ':' in namespace.repository: + raise InvalidArgumentValueError("Parameter 'name' refers to a repository and" + " should not include a tag or digest.") 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 185a1fd3d5e..0e956c138fa 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/commands.py +++ b/src/azure-cli/azure/cli/command_modules/acr/commands.py @@ -28,7 +28,9 @@ token_credential_output_format, agentpool_output_format, connected_registry_output_format, - connected_registry_list_output_format + connected_registry_list_output_format, + list_referrers_output_format, + manifest_output_format, ) from ._client_factory import ( cf_acr_registries, @@ -46,7 +48,9 @@ ) -def load_command_table(self, _): # pylint: disable=too-many-statements +# pylint: disable=too-many-locals +# pylint: disable=too-many-statements +def load_command_table(self, _): acr_custom_util = CliCommandType( operations_tmpl='azure.cli.command_modules.acr.custom#{}', @@ -79,6 +83,10 @@ def load_command_table(self, _): # pylint: disable=too-many-statements operations_tmpl='azure.cli.command_modules.acr.repository#{}' ) + acr_manifest_util = CliCommandType( + operations_tmpl='azure.cli.command_modules.acr.manifest#{}' + ) + acr_webhook_util = CliCommandType( operations_tmpl='azure.cli.command_modules.acr.webhook#{}', table_transformer=webhook_output_format, @@ -197,12 +205,24 @@ def load_command_table(self, _): # pylint: disable=too-many-statements with self.command_group('acr repository', acr_repo_util) as g: g.command('list', 'acr_repository_list') g.command('show-tags', 'acr_repository_show_tags') - g.command('show-manifests', 'acr_repository_show_manifests') + g.command('show-manifests', 'acr_repository_show_manifests', + deprecate_info=self.deprecate(redirect='acr manifest metadata list', hide=True)) g.show_command('show', 'acr_repository_show') g.command('update', 'acr_repository_update') g.command('delete', 'acr_repository_delete') g.command('untag', 'acr_repository_untag') + with self.command_group('acr manifest', acr_manifest_util, is_preview=True) as g: + g.show_command('show', 'acr_manifest_show') + g.command('list', 'acr_manifest_list', table_transformer=manifest_output_format) + g.command('delete', 'acr_manifest_delete') + g.command('list-referrers', 'acr_manifest_list_referrers', table_transformer=list_referrers_output_format) + + with self.command_group('acr manifest metadata', acr_manifest_util, is_preview=True) as g: + g.show_command('show', 'acr_manifest_metadata_show') + g.command('list', 'acr_manifest_metadata_list') + g.command('update', 'acr_manifest_metadata_update') + with self.command_group('acr webhook', acr_webhook_util) as g: g.command('list', 'acr_webhook_list') g.command('create', 'acr_webhook_create') diff --git a/src/azure-cli/azure/cli/command_modules/acr/manifest.py b/src/azure-cli/azure/cli/command_modules/acr/manifest.py new file mode 100644 index 00000000000..c37c26353eb --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/acr/manifest.py @@ -0,0 +1,454 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +try: + from urllib.parse import unquote +except ImportError: + from urllib import unquote + +from knack.log import get_logger +from azure.cli.core.util import user_confirmation +from azure.cli.core.azclierror import InvalidArgumentValueError + +from .repository import ( + _parse_image_name, + get_image_digest, + _obtain_data_from_registry, + _get_manifest_path, + _acr_repository_attributes_helper +) + +from ._docker_utils import ( + request_data_from_registry, + get_access_credentials, + get_login_server_suffix, + RepoAccessTokenPermission +) + +from ._validators import ( + BAD_MANIFEST_FQDN, + BAD_REPO_FQDN +) + +logger = get_logger(__name__) + +ORDERBY_PARAMS = { + 'time_asc': 'timeasc', + 'time_desc': 'timedesc' +} +DEFAULT_PAGINATION = 100 + +BAD_ARGS_ERROR_REPO = "You must provide either a fully qualified repository specifier such as"\ + " 'MyRegistry.azurecr.io/hello-world' as a positional parameter or"\ + " provide '-r MyRegistry -n hello-world' argument values." +BAD_ARGS_ERROR_MANIFEST = "You must provide either a fully qualified manifest specifier such as"\ + " 'MyRegistry.azurecr.io/hello-world:latest' as a positional parameter or provide"\ + " '-r MyRegistry -n hello-world:latest' argument values." + + +def _get_v2_manifest_path(repository, manifest): + return '/v2/{}/manifests/{}'.format(repository, manifest) + + +def _get_referrers_path(repository, manifest): + return '/oras/artifacts/v1/{}/manifests/{}/referrers'.format(repository, manifest) + + +def _obtain_manifest_from_registry(login_server, + path, + username, + password, + raw=False): + + result, _ = request_data_from_registry(http_method='get', + login_server=login_server, + path=path, + raw=raw, + username=username, + password=password, + result_index=None, + manifest_headers=True) + + return result + + +def _obtain_referrers_from_registry(login_server, + path, + username, + password, + artifact_type=None): + + result_list = {'references': []} + execute_next_http_call = True + + params = { + 'artifactType': artifact_type + } + + while execute_next_http_call: + execute_next_http_call = False + + result, next_link = request_data_from_registry( + http_method='get', + login_server=login_server, + path=path, + username=username, + password=password, + result_index=None, + params=params) + + if result: + result_list['references'].extend(result['references']) + + if next_link: + # The registry is telling us there's more items in the list, + # and another call is needed. The link header looks something + # like `Link: ; rel="next"` + # we should follow the next path indicated in the link header + next_link_path = next_link[(next_link.index('<') + 1):next_link.index('>')] + tokens = next_link_path.split('?', 1) + params = {y[0]: unquote(y[1]) for y in (x.split('=', 1) for x in tokens[1].split('&'))} + + execute_next_http_call = True + + return result_list + + +def _parse_fqdn(cmd, fqdn, is_manifest=True): + try: + fqdn = fqdn.lstrip('https://') + reg_addr = fqdn.split('/', 1)[0] + registry_name = reg_addr.split('.', 1)[0] + reg_suffix = '.' + reg_addr.split('.', 1)[1] + manifest_spec = fqdn.split('/', 1)[1] + + _validate_login_server_suffix(cmd, reg_suffix) + + # We must check for this here as the default tag 'latest' gets added in _parse_image_name + if not is_manifest and ':' in manifest_spec: + raise InvalidArgumentValueError("The positional parameter 'repo_id'" + " should not include a tag or digest.") + + repository, tag, manifest = _parse_image_name(manifest_spec, allow_digest=True) + + except IndexError as e: + if is_manifest: + raise InvalidArgumentValueError(BAD_MANIFEST_FQDN) from e + + raise InvalidArgumentValueError(BAD_REPO_FQDN) from e + + return registry_name, repository, tag, manifest + + +def _validate_login_server_suffix(cmd, reg_suffix): + cli_ctx = cmd.cli_ctx + login_server_suffix = get_login_server_suffix(cli_ctx) + + if reg_suffix != login_server_suffix: + raise InvalidArgumentValueError(f'Provided registry suffix \'{reg_suffix}\' does not match the configured az' + f' cli acr login server suffix \'{login_server_suffix}\'. Check the' + ' \'acrLoginServerEndpoint\' value when running \'az cloud show\'.') + + +def acr_manifest_list(cmd, + registry_name=None, + repository=None, + repo_id=None, + top=None, + orderby=None, + tenant_suffix=None, + username=None, + password=None): + if (repo_id and repository) or (not repo_id and not (registry_name and repository)): + raise InvalidArgumentValueError(BAD_ARGS_ERROR_REPO) + + if repo_id: + registry_name, repository, _, _ = _parse_fqdn(cmd, repo_id[0], is_manifest=False) + + login_server, username, password = get_access_credentials( + cmd=cmd, + registry_name=registry_name, + tenant_suffix=tenant_suffix, + username=username, + password=password, + repository=repository, + permission=RepoAccessTokenPermission.PULL_META_READ.value) + + raw_result = _obtain_data_from_registry( + login_server=login_server, + path=_get_manifest_path(repository), + top=top, + username=username, + password=password, + result_index='manifests', + orderby=orderby) + + digest_list = [x['digest'] for x in raw_result] + manifest_list = [] + + for digest in digest_list: + manifest_list.append(_obtain_manifest_from_registry( + login_server=login_server, + path=_get_v2_manifest_path(repository, digest), + username=username, + password=password)) + + return manifest_list + + +def acr_manifest_metadata_list(cmd, + registry_name=None, + repository=None, + repo_id=None, + top=None, + orderby=None, + tenant_suffix=None, + username=None, + password=None): + if (repo_id and repository) or (not repo_id and not (registry_name and repository)): + raise InvalidArgumentValueError(BAD_ARGS_ERROR_REPO) + + if repo_id: + registry_name, repository, _, _ = _parse_fqdn(cmd, repo_id[0], is_manifest=False) + + login_server, username, password = get_access_credentials( + cmd=cmd, + registry_name=registry_name, + tenant_suffix=tenant_suffix, + username=username, + password=password, + repository=repository, + permission=RepoAccessTokenPermission.METADATA_READ.value) + + raw_result = _obtain_data_from_registry( + login_server=login_server, + path=_get_manifest_path(repository), + username=username, + password=password, + result_index='manifests', + top=top, + orderby=orderby) + + return raw_result + + +def acr_manifest_list_referrers(cmd, + registry_name=None, + manifest_spec=None, + artifact_type=None, + manifest_id=None, + recursive=False, + tenant_suffix=None, + username=None, + password=None): + if (manifest_id and manifest_spec) or (not manifest_id and not (registry_name and manifest_spec)): + raise InvalidArgumentValueError(BAD_ARGS_ERROR_MANIFEST) + + if manifest_id: + registry_name, repository, tag, manifest = _parse_fqdn(cmd, manifest_id[0]) + + else: + repository, tag, manifest = _parse_image_name(manifest_spec, allow_digest=True) + + if not manifest: + image = repository + ':' + tag + repository, tag, manifest = get_image_digest(cmd, registry_name, image) + + login_server, username, password = get_access_credentials( + cmd=cmd, + registry_name=registry_name, + tenant_suffix=tenant_suffix, + username=username, + password=password, + repository=repository, + permission=RepoAccessTokenPermission.PULL.value) + + raw_result = _obtain_referrers_from_registry( + login_server=login_server, + path=_get_referrers_path(repository, manifest), + username=username, + password=password, + artifact_type=artifact_type) + + ref_key = "references" + if recursive: + for referrers_obj in raw_result[ref_key]: + internal_referrers_obj = _obtain_referrers_from_registry( + login_server=login_server, + path=_get_referrers_path(repository, referrers_obj["digest"]), + username=username, + password=password) + + for ref in internal_referrers_obj[ref_key]: + raw_result[ref_key].append(ref) + + return raw_result + + +def acr_manifest_show(cmd, + registry_name=None, + manifest_spec=None, + manifest_id=None, + raw_output=None, + tenant_suffix=None, + username=None, + password=None): + if (manifest_id and manifest_spec) or (not manifest_id and not (registry_name and manifest_spec)): + raise InvalidArgumentValueError(BAD_ARGS_ERROR_MANIFEST) + + if manifest_id: + registry_name, repository, tag, manifest = _parse_fqdn(cmd, manifest_id[0]) + + else: + repository, tag, manifest = _parse_image_name(manifest_spec, allow_digest=True) + + if not manifest: + image = repository + ':' + tag + repository, tag, manifest = get_image_digest(cmd, registry_name, image) + + login_server, username, password = get_access_credentials( + cmd=cmd, + registry_name=registry_name, + tenant_suffix=tenant_suffix, + username=username, + password=password, + repository=repository, + permission=RepoAccessTokenPermission.PULL.value) + + raw_result = _obtain_manifest_from_registry( + login_server=login_server, + path=_get_v2_manifest_path(repository, manifest), + raw=raw_output, + username=username, + password=password) + + # We are forced to print directly here in order to preserve bit for bit integrity and + # avoid any formatting so that the output can successfully be hashed. Customer will expect that + # 'az acr manifest show myreg.azurecr.io/myrepo@sha256:abc123 --raw | shasum -a 256' will result in 'abc123' + if raw_output: + print(raw_result, end='') + return + + return raw_result + + +def acr_manifest_metadata_show(cmd, + registry_name=None, + manifest_spec=None, + manifest_id=None, + tenant_suffix=None, + username=None, + password=None): + if (manifest_id and manifest_spec) or (not manifest_id and not (registry_name and manifest_spec)): + raise InvalidArgumentValueError(BAD_ARGS_ERROR_MANIFEST) + + if manifest_id: + registry_name, repository, tag, manifest = _parse_fqdn(cmd, manifest_id[0]) + manifest_spec = repository + ':' + tag if tag else repository + '@' + manifest + + return _acr_repository_attributes_helper( + cmd=cmd, + registry_name=registry_name, + http_method='get', + json_payload=None, + permission=RepoAccessTokenPermission.METADATA_READ.value, + repository=None, + image=manifest_spec, + tenant_suffix=tenant_suffix, + username=username, + password=password) + + +def acr_manifest_metadata_update(cmd, + registry_name=None, + manifest_spec=None, + manifest_id=None, + tenant_suffix=None, + username=None, + password=None, + delete_enabled=None, + list_enabled=None, + read_enabled=None, + write_enabled=None): + if (manifest_id and manifest_spec) or (not manifest_id and not (registry_name and manifest_spec)): + raise InvalidArgumentValueError(BAD_ARGS_ERROR_MANIFEST) + + if manifest_id: + registry_name, repository, tag, manifest = _parse_fqdn(cmd, manifest_id[0]) + manifest_spec = repository + ':' + tag if tag else repository + '@' + manifest + + json_payload = {} + + if delete_enabled is not None: + json_payload.update({ + 'deleteEnabled': delete_enabled + }) + if list_enabled is not None: + json_payload.update({ + 'listEnabled': list_enabled + }) + if read_enabled is not None: + json_payload.update({ + 'readEnabled': read_enabled + }) + if write_enabled is not None: + json_payload.update({ + 'writeEnabled': write_enabled + }) + + permission = RepoAccessTokenPermission.META_WRITE_META_READ.value if json_payload \ + else RepoAccessTokenPermission.METADATA_READ.value + return _acr_repository_attributes_helper( + cmd=cmd, + registry_name=registry_name, + http_method='patch' if json_payload else 'get', + json_payload=json_payload, + permission=permission, + repository=None, + image=manifest_spec, + tenant_suffix=tenant_suffix, + username=username, + password=password) + + +def acr_manifest_delete(cmd, + registry_name=None, + manifest_spec=None, + manifest_id=None, + tenant_suffix=None, + username=None, + password=None, + yes=False): + if (manifest_id and manifest_spec) or (not manifest_id and not (registry_name and manifest_spec)): + raise InvalidArgumentValueError(BAD_ARGS_ERROR_MANIFEST) + + if manifest_id: + registry_name, repository, tag, manifest = _parse_fqdn(cmd, manifest_id[0]) + + else: + repository, tag, manifest = _parse_image_name(manifest_spec, allow_digest=True) + + if not manifest: + image = repository + ':' + tag + repository, tag, manifest = get_image_digest(cmd, registry_name, image) + + login_server, username, password = get_access_credentials( + cmd=cmd, + registry_name=registry_name, + tenant_suffix=tenant_suffix, + username=username, + password=password, + repository=repository, + permission=RepoAccessTokenPermission.DELETE.value) + + user_confirmation("Are you sure you want to delete the artifact '{}'" + " and all manifests that refer to it?".format(manifest), yes) + + return request_data_from_registry( + http_method='delete', + login_server=login_server, + path=_get_v2_manifest_path(repository, manifest), + username=username, + password=password)[0] diff --git a/src/azure-cli/azure/cli/command_modules/acr/repository.py b/src/azure-cli/azure/cli/command_modules/acr/repository.py index c48b6e14d27..bb9777fd15b 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/repository.py +++ b/src/azure-cli/azure/cli/command_modules/acr/repository.py @@ -194,6 +194,7 @@ def acr_repository_show_tags(cmd, return raw_result +# This command lists manifest metadata; deprecated for manifest metadata list def acr_repository_show_manifests(cmd, registry_name, repository, diff --git a/src/azure-cli/azure/cli/command_modules/acr/tests/latest/test_acr_commands_mock.py b/src/azure-cli/azure/cli/command_modules/acr/tests/latest/test_acr_commands_mock.py index 77fe87b1d68..68c06c3ad16 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/tests/latest/test_acr_commands_mock.py +++ b/src/azure-cli/azure/cli/command_modules/acr/tests/latest/test_acr_commands_mock.py @@ -23,6 +23,17 @@ acr_repository_delete, acr_repository_untag ) + +from azure.cli.command_modules.acr.manifest import ( + acr_manifest_show, + acr_manifest_list, + acr_manifest_delete, + acr_manifest_list_referrers, + acr_manifest_metadata_show, + acr_manifest_metadata_list, + acr_manifest_metadata_update +) + from azure.cli.command_modules.acr.helm import ( acr_helm_list, acr_helm_show, @@ -33,6 +44,7 @@ get_login_credentials, get_access_credentials, get_authorization_header, + get_manifest_authorization_header, RepoAccessTokenPermission, HelmAccessTokenPermission, EMPTY_GUID @@ -256,7 +268,7 @@ def test_repository_show(self, mock_requests_get, mock_get_access_credentials): @mock.patch('azure.cli.command_modules.acr.repository.get_access_credentials', autospec=True) @mock.patch('requests.request', autospec=True) - def test_repository_show(self, mock_requests_get, mock_get_access_credentials): + def test_repository_update(self, mock_requests_get, mock_get_access_credentials): cmd = self._setup_cmd() response = mock.MagicMock() @@ -387,6 +399,426 @@ def test_repository_delete(self, mock_requests_delete, mock_get_manifest_digest, timeout=300, verify=mock.ANY) + + @mock.patch('azure.cli.command_modules.acr.manifest.get_access_credentials', autospec=True) + @mock.patch('azure.cli.command_modules.acr.repository.get_access_credentials', autospec=True) + @mock.patch('azure.cli.command_modules.acr.repository._get_manifest_digest', autospec=True) + @mock.patch('requests.request', autospec=True) + def test_manifest_show(self, mock_requests_get, mock_get_manifest_digest, mock_get_access_credentials, mock_get_access_credentials_manifest): + cmd = self._setup_cmd() + + response = mock.MagicMock() + response.headers = {} + response.status_code = 200 + response.content = json.dumps({ + 'schemaVersion': 2, + 'mediaType': 'application/vnd.docker.distribution.manifest.v2+json' + }).encode() + + mock_requests_get.return_value = response + mock_get_access_credentials.return_value = 'testregistry.azurecr.io', 'username', 'password' + mock_get_access_credentials_manifest.return_value = 'testregistry.azurecr.io', 'username', 'password' + mock_get_manifest_digest.return_value = 'sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7' + + # Get manifest by tag + acr_manifest_show(cmd, + registry_name='testregistry', + manifest_spec='testrepository:testtag') + mock_requests_get.assert_called_with( + method='get', + url='https://testregistry.azurecr.io/v2/testrepository/manifests/sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7', + headers=get_manifest_authorization_header('username', 'password'), + params=None, + json=None, + timeout=300, + verify=mock.ANY) + + # Get manifest by digest + acr_manifest_show(cmd, + registry_name='testregistry', + manifest_spec='testrepository@sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7') + mock_requests_get.assert_called_with( + method='get', + url='https://testregistry.azurecr.io/v2/testrepository/manifests/sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7', + headers=get_manifest_authorization_header('username', 'password'), + params=None, + json=None, + timeout=300, + verify=mock.ANY) + + # Get manifest by fqdn + acr_manifest_show(cmd, + manifest_id=['testregistry.azurecr.io/testrepository:testtag']) + mock_requests_get.assert_called_with( + method='get', + url='https://testregistry.azurecr.io/v2/testrepository/manifests/sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7', + headers=get_manifest_authorization_header('username', 'password'), + params=None, + json=None, + timeout=300, + verify=mock.ANY) + + + @mock.patch('azure.cli.command_modules.acr.manifest.get_access_credentials', autospec=True) + @mock.patch('azure.cli.command_modules.acr.repository.get_access_credentials', autospec=True) + @mock.patch('azure.cli.command_modules.acr.repository._get_manifest_digest', autospec=True) + @mock.patch('azure.cli.command_modules.acr.manifest._obtain_data_from_registry', autospec=True) + @mock.patch('azure.cli.command_modules.acr.manifest._obtain_manifest_from_registry', autospec=True) + def test_manifest_list(self, mock_obtain_manifest_from_registry, mock_obtain_data_from_registry, mock_get_manifest_digest, mock_get_access_credentials, mock_get_access_credentials_manifest): + cmd = self._setup_cmd() + + metadata_response = [ + {'digest': 'sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7'}, + {'digest': 'sha256:844f934b1ed1919a8ce43409519c0f853c6e8c1d58dd58df0a14e1dcad69a325'} + ] + + manifest_response1 = { + 'name': 'manifest1', + 'schemaVersion': 2, + 'mediaType': 'application/vnd.docker.distribution.manifest.v2+json' + } + + manifest_response2 = { + 'name': 'manifest2', + 'schemaVersion': 2, + 'mediaType': 'application/vnd.docker.distribution.manifest.v2+json' + } + + expected_output = [ + { + 'name': 'manifest1', + 'schemaVersion': 2, + 'mediaType': 'application/vnd.docker.distribution.manifest.v2+json' + }, + { + 'name': 'manifest2', + 'schemaVersion': 2, + 'mediaType': 'application/vnd.docker.distribution.manifest.v2+json' + }] + + mock_obtain_data_from_registry.return_value = metadata_response + mock_obtain_manifest_from_registry.side_effect = [manifest_response1, manifest_response2, manifest_response1, manifest_response2] + mock_get_access_credentials.return_value = 'testregistry.azurecr.io', 'username', 'password' + mock_get_access_credentials_manifest.return_value = 'testregistry.azurecr.io', 'username', 'password' + + # List manifests + result = acr_manifest_list(cmd, + registry_name='testregistry', + repository='testrepository') + + assert result == expected_output + + # List manifests by fqdn + result = acr_manifest_list(cmd, + repo_id=['testregistry.azurecr.io/testrepository']) + + assert result == expected_output + + + @mock.patch('azure.cli.command_modules.acr.manifest.get_access_credentials', autospec=True) + @mock.patch('azure.cli.command_modules.acr.repository.get_access_credentials', autospec=True) + @mock.patch('azure.cli.command_modules.acr.repository._get_manifest_digest', autospec=True) + @mock.patch('requests.request', autospec=True) + def test_manifest_list_referrers(self, mock_requests_get, mock_get_manifest_digest, mock_get_access_credentials, mock_get_access_credentials_manifest): + cmd = self._setup_cmd() + + referrers_response = mock.MagicMock() + referrers_response.headers = {} + referrers_response.status_code = 200 + referrers_response.content = json.dumps({ + 'referrers': {} + }).encode() + + mock_requests_get.return_value = referrers_response + mock_get_access_credentials.return_value = 'testregistry.azurecr.io', 'username', 'password' + mock_get_access_credentials_manifest.return_value = 'testregistry.azurecr.io', 'username', 'password' + mock_get_manifest_digest.return_value = 'sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7' + + # List referrers by tag + acr_manifest_list_referrers(cmd, + registry_name='testregistry', + manifest_spec='testrepository:testtag') + mock_requests_get.assert_called_with( + method='get', + url='https://testregistry.azurecr.io/oras/artifacts/v1/testrepository/manifests/sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7/referrers', + headers=get_authorization_header('username', 'password'), + params={'artifactType': None}, + json=None, + timeout=300, + verify=mock.ANY) + + # List referrers by digest + acr_manifest_list_referrers(cmd, + registry_name='testregistry', + manifest_spec='testrepository@sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7') + mock_requests_get.assert_called_with( + method='get', + url='https://testregistry.azurecr.io/oras/artifacts/v1/testrepository/manifests/sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7/referrers', + headers=get_authorization_header('username', 'password'), + params={'artifactType': None}, + json=None, + timeout=300, + verify=mock.ANY) + + # List referrers by fqdn and filter by artifact type + acr_manifest_list_referrers(cmd, + manifest_id=['testregistry.azurecr.io/testrepository:testtag'], + artifact_type='sbom/example') + mock_requests_get.assert_called_with( + method='get', + url='https://testregistry.azurecr.io/oras/artifacts/v1/testrepository/manifests/sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7/referrers', + headers=get_authorization_header('username', 'password'), + params={'artifactType': 'sbom/example'}, + json=None, + timeout=300, + verify=mock.ANY) + + + @mock.patch('azure.cli.command_modules.acr.manifest.get_access_credentials', autospec=True) + @mock.patch('azure.cli.command_modules.acr.repository.get_access_credentials', autospec=True) + @mock.patch('azure.cli.command_modules.acr.repository._get_manifest_digest', autospec=True) + @mock.patch('requests.request', autospec=True) + def test_manifest_delete(self, mock_requests_delete, mock_get_manifest_digest, mock_get_access_credentials, mock_get_access_credentials_manifest): + cmd = self._setup_cmd() + + delete_response = mock.MagicMock() + delete_response.headers = {} + delete_response.status_code = 200 + + mock_requests_delete.return_value = delete_response + mock_get_access_credentials.return_value = 'testregistry.azurecr.io', 'username', 'password' + mock_get_access_credentials_manifest.return_value = 'testregistry.azurecr.io', 'username', 'password' + mock_get_manifest_digest.return_value = 'sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7' + + # Delete artifact by tag + acr_manifest_delete(cmd, + registry_name='testregistry', + manifest_spec='testrepository:testtag', + yes=True) + mock_requests_delete.assert_called_with( + method='delete', + url='https://testregistry.azurecr.io/v2/testrepository/manifests/sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7', + headers=get_authorization_header('username', 'password'), + params=None, + json=None, + timeout=300, + verify=mock.ANY) + + # Delete artifact by manifest digest + acr_manifest_delete(cmd, + registry_name='testregistry', + manifest_spec='testrepository@sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7', + yes=True) + mock_requests_delete.assert_called_with( + method='delete', + url='https://testregistry.azurecr.io/v2/testrepository/manifests/sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7', + headers=get_authorization_header('username', 'password'), + params=None, + json=None, + timeout=300, + verify=mock.ANY) + + # Delete artifact by fqdn + acr_manifest_delete(cmd, + manifest_id=['testregistry.azurecr.io/testrepository:testtag'], + yes=True) + mock_requests_delete.assert_called_with( + method='delete', + url='https://testregistry.azurecr.io/v2/testrepository/manifests/sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7', + headers=get_authorization_header('username', 'password'), + params=None, + json=None, + timeout=300, + verify=mock.ANY) + + + @mock.patch('azure.cli.command_modules.acr.manifest.get_access_credentials', autospec=True) + @mock.patch('azure.cli.command_modules.acr.repository.get_access_credentials', autospec=True) + @mock.patch('azure.cli.command_modules.acr.repository._get_manifest_digest', autospec=True) + @mock.patch('requests.request', autospec=True) + def test_manifest_metadata_show(self, mock_requests_get, mock_get_manifest_digest, mock_get_access_credentials, mock_get_access_credentials_manifest): + cmd = self._setup_cmd() + + response = mock.MagicMock() + response.headers = {} + response.status_code = 200 + response.content = json.dumps({ + 'registry': 'testregistry.azurecr.io', + 'imageName': 'testrepository' + }).encode() + + mock_requests_get.return_value = response + mock_get_access_credentials.return_value = 'testregistry.azurecr.io', 'username', 'password' + mock_get_access_credentials_manifest.return_value = 'testregistry.azurecr.io', 'username', 'password' + mock_get_manifest_digest.return_value = 'sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7' + + + # Show metadata for an artifact by tag + acr_manifest_metadata_show(cmd, + registry_name='testregistry', + manifest_spec='testrepository:testtag') + mock_requests_get.assert_called_with( + method='get', + url='https://testregistry.azurecr.io/acr/v1/testrepository/_tags/testtag', + headers=get_authorization_header('username', 'password'), + params=None, + json=None, + timeout=300, + verify=mock.ANY) + + # Show metadata for an artifact by manifest digest + acr_manifest_metadata_show(cmd, + registry_name='testregistry', + manifest_spec='testrepository@sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7') + mock_requests_get.assert_called_with( + method='get', + url='https://testregistry.azurecr.io/acr/v1/testrepository/_manifests/sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7', + headers=get_authorization_header('username', 'password'), + params=None, + json=None, + timeout=300, + verify=mock.ANY) + + # Show metadata for an artifact by fqdn + acr_manifest_metadata_show(cmd, + manifest_id=['testregistry.azurecr.io/testrepository:testtag']) + + mock_requests_get.assert_called_with( + method='get', + url='https://testregistry.azurecr.io/acr/v1/testrepository/_tags/testtag', + headers=get_authorization_header('username', 'password'), + params=None, + json=None, + timeout=300, + verify=mock.ANY) + + + @mock.patch('azure.cli.command_modules.acr.manifest.get_access_credentials', autospec=True) + @mock.patch('azure.cli.command_modules.acr.repository.get_access_credentials', autospec=True) + @mock.patch('requests.request', autospec=True) + def test_manifest_metadata_list(self, mock_requests_get, mock_get_access_credentials, mock_get_access_credentials_manifest): + cmd = self._setup_cmd() + + response = mock.MagicMock() + response.headers = {} + response.status_code = 200 + response.content = json.dumps({'manifests': [ + { + 'digest': 'sha256:b972dda797ef258a7ea5738eb2109778c2bac6a99d1033e6c9f9bdb4fbd196e7', + 'tags': ['testtag1', 'testtag2'] + }, + { + 'digest': 'sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7', + 'tags': ['testtag3'] + }]}).encode() + + mock_requests_get.return_value = response + mock_get_access_credentials.return_value = 'testregistry.azurecr.io', 'username', 'password' + mock_get_access_credentials_manifest.return_value = 'testregistry.azurecr.io', 'username', 'password' + + # Show manifest metadata using Basic auth + acr_manifest_metadata_list(cmd, + registry_name='testregistry', + repository='testrepository') + mock_requests_get.assert_called_with( + method='get', + url='https://testregistry.azurecr.io/acr/v1/testrepository/_manifests', + headers=get_authorization_header('username', 'password'), + params={ + 'n': 100, + 'orderby': None + }, + json=None, + timeout=300, + verify=mock.ANY) + + # Show manifests using Bearer auth using fqdn + mock_get_access_credentials.return_value = 'testregistry.azurecr.io', EMPTY_GUID, 'password' + mock_get_access_credentials_manifest.return_value = 'testregistry.azurecr.io', EMPTY_GUID, 'password' + + acr_manifest_metadata_list(cmd, + repo_id=['testregistry.azurecr.io/testrepository'], + top=10, + orderby='time_desc') + mock_requests_get.assert_called_with( + method='get', + url='https://testregistry.azurecr.io/acr/v1/testrepository/_manifests', + headers=get_authorization_header(EMPTY_GUID, 'password'), + params={ + 'n': 10, + 'orderby': 'timedesc' + }, + json=None, + timeout=300, + verify=mock.ANY) + + + @mock.patch('azure.cli.command_modules.acr.repository.get_access_credentials', autospec=True) + @mock.patch('azure.cli.command_modules.acr.repository._get_manifest_digest', autospec=True) + @mock.patch('requests.request', autospec=True) + def test_manifest_metadata_update(self, mock_requests_patch, mock_get_manifest_digest, mock_get_access_credentials): + cmd = self._setup_cmd() + + response = mock.MagicMock() + response.headers = {} + response.status_code = 200 + response.content = json.dumps({ + 'registry': 'testregistry.azurecr.io', + 'imageName': 'testrepository' + }).encode() + + mock_requests_patch.return_value = response + mock_get_access_credentials.return_value = 'testregistry.azurecr.io', 'username', 'password' + mock_get_manifest_digest.return_value = 'sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7' + + # Update attributes for an artifact by tag + acr_manifest_metadata_update(cmd, + registry_name='testregistry', + manifest_spec='testrepository:testtag', + write_enabled='false') + mock_requests_patch.assert_called_with( + method='patch', + url='https://testregistry.azurecr.io/acr/v1/testrepository/_tags/testtag', + headers=get_authorization_header('username', 'password'), + params=None, + json={ + 'writeEnabled': 'false' + }, + timeout=300, + verify=mock.ANY) + + # Update attributes for an artifact by manifest digest + acr_manifest_metadata_update(cmd, + registry_name='testregistry', + manifest_spec='testrepository@sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7', + write_enabled='false') + mock_requests_patch.assert_called_with( + method='patch', + url='https://testregistry.azurecr.io/acr/v1/testrepository/_manifests/sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7', + headers=get_authorization_header('username', 'password'), + params=None, + json={ + 'writeEnabled': 'false' + }, + timeout=300, + verify=mock.ANY) + + # Update attributes for an artifact by fqdn + acr_manifest_metadata_update(cmd, + manifest_id=['testregistry.azurecr.io/testrepository:testtag'], + write_enabled='false') + mock_requests_patch.assert_called_with( + method='patch', + url='https://testregistry.azurecr.io/acr/v1/testrepository/_tags/testtag', + headers=get_authorization_header('username', 'password'), + params=None, + json={ + 'writeEnabled': 'false' + }, + timeout=300, + verify=mock.ANY) + + @mock.patch('azure.cli.core._profile.Profile.get_subscription_id', autospec=True) @mock.patch('azure.cli.command_modules.acr._docker_utils.get_registry_by_name', autospec=True) @mock.patch('requests.post', autospec=True)