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 9ab111d52ce..1d59680266c 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/_help.py +++ b/src/azure-cli/azure/cli/command_modules/acr/_help.py @@ -1327,6 +1327,21 @@ text: > az acr connected-registry install renew-credentials -r mycloudregistry -n myconnectedregistry """ + +helps['acr connected-registry repo'] = """ +type: command +short-summary: Updates all the necessary connected registry sync scope maps repository permissions. +examples: + - name: Adds permissions to synchronize images from 'repo1' and 'repo2' to the connected registry 'myconnectedregistry' and its ancestors. + text: > + az acr connected-registry repo -r mycloudregistry -n myconnectedregistry --add repo1 repo2 + - name: Removes permissions to synchronize images from 'repo1' and 'repo2' to the connected registry 'myconnectedregistry' and its descendants. + text: > + az acr connected-registry repo -r mycloudregistry -n myconnectedregistry --remove repo1 repo2 + - name: Removes permissions to synchronize 'repo1' images and adds permissions for 'repo2' images. + text: > + az acr connected-registry repo -r mycloudregistry -n myconnectedregistry --remove repo1 --add repo2 +""" # endregion # region private-endpoint-connection 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 dc2f2390b4d..02c6682c3eb 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/_params.py +++ b/src/azure-cli/azure/cli/command_modules/acr/_params.py @@ -407,7 +407,7 @@ def load_arguments(self, _): # pylint: disable=too-many-statements with self.argument_context('acr connected-registry create') as c: c.argument('log_level', help='Sets the log level for logging on the instance. Accepted log levels are Debug, Information, Warning, Error, and None.', required=False, default="Information") - c.argument('mode', options_list=['--mode', '-m'], help='Can be one of the two operating modes: registry or mirror(pull-only mode).', required=False, default="registry") + c.argument('mode', options_list=['--mode', '-m'], help='Can be one of the two operating modes: registry or mirror(pull-only mode).', required=False, default="Registry") c.argument('client_token_list', options_list=['--client-tokens'], nargs='+', help='Specifies the client access to the repositories in the connected registry. It can be in the format [TOKEN_NAME01] [TOKEN_NAME02]...', required=False) c.argument('sync_window', options_list=['--sync-window', '-w'], help='Required parameter if --sync-schedule is present. Used to determine the schedule duration. Uses ISO 8601 duration format.', required=False) c.argument('sync_schedule', options_list=['--sync-schedule', '-s'], help='Optional parameter to define the sync schedule. Uses cron expression to determine the schedule. If not specified, the instance is considered always online and attempts to sync every minute.', required=False, default="* * * * *") @@ -423,6 +423,12 @@ def load_arguments(self, _): # pylint: disable=too-many-statements c.argument('sync_schedule', options_list=['--sync-schedule', '-s'], help='Optional parameter to define the sync schedule. Uses cron expression to determine the schedule. If not specified, the instance is considered always online and attempts to sync every minute.', required=False) c.argument('sync_message_ttl', help='Determines how long the sync messages will be kept in the cloud. Uses ISO 8601 duration format.', required=False) + with self.argument_context('acr connected-registry repo') as c: + c.argument('add_repos', options_list=['--add'], nargs='*', required=False, + help='repository permissions to be added to the targeted connected registry and it\'s ancestors sync scope maps. Use the format "--add [REPO1 REPO2 ...]" per flag. ' + repo_valid_actions) + c.argument('remove_repos', options_list=['--remove'], nargs='*', required=False, + help='respsitory permissions to be removed from the targeted connected registry and it\'s succesors sync scope maps. Use the format "--remove [REPO1 REPO2 ...]" per flag. ' + repo_valid_actions) + def _get_helm_default_install_location(): exe_name = 'helm' diff --git a/src/azure-cli/azure/cli/command_modules/acr/_utils.py b/src/azure-cli/azure/cli/command_modules/acr/_utils.py index 95e5176510b..0d07040588f 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/_utils.py +++ b/src/azure-cli/azure/cli/command_modules/acr/_utils.py @@ -472,7 +472,7 @@ def parse_repositories_from_actions(actions): return list(set(repositories)) -def parse_scope_map_actions(repository_actions_list, gateway_actions_list): +def parse_scope_map_actions(repository_actions_list=None, gateway_actions_list=None): from .scope_map import RepoScopeMapActions, GatewayScopeMapActions valid_actions = {action.value for action in RepoScopeMapActions} actions = _parse_scope_map_actions(repository_actions_list, valid_actions, 'repositories') @@ -486,7 +486,7 @@ def _parse_scope_map_actions(actions_list, valid_actions, action_prefix): return [] actions = [] for rule in actions_list: - resource = rule[0] + resource = rule[0].lower() if len(rule) < 2: raise CLIError('At least one action must be specified with "{}".'.format(resource)) for action in rule[1:]: 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 6f8618a6e3b..e599217b97f 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/commands.py +++ b/src/azure-cli/azure/cli/command_modules/acr/commands.py @@ -359,6 +359,7 @@ def _helm_deprecate_message(self): g.show_command('show', 'acr_connected_registry_show') g.command('deactivate', 'acr_connected_registry_deactivate') g.command('update', 'acr_connected_registry_update') + g.command('repo', 'acr_connected_registry_repo') g.command('install info', 'acr_connected_registry_install_info') g.command('install renew-credentials', 'acr_connected_registry_install_renew_credentials') g.command('list', 'acr_connected_registry_list', diff --git a/src/azure-cli/azure/cli/command_modules/acr/connected_registry.py b/src/azure-cli/azure/cli/command_modules/acr/connected_registry.py index 616e5620ae6..a0146c215c3 100644 --- a/src/azure-cli/azure/cli/command_modules/acr/connected_registry.py +++ b/src/azure-cli/azure/cli/command_modules/acr/connected_registry.py @@ -11,18 +11,25 @@ from azure.cli.core.commands.client_factory import get_subscription_id from ._client_factory import cf_acr_tokens, cf_acr_scope_maps from ._utils import ( - get_registry_by_name, - validate_managed_registry, - user_confirmation, + build_token_id, create_default_scope_map, + get_registry_by_name, + get_scope_map_from_id, get_token_from_id, - build_token_id + parse_scope_map_actions, + user_confirmation, + validate_managed_registry ) class ConnectedRegistryModes(Enum): - MIRROR = 'mirror' - REGISTRY = 'registry' + MIRROR = 'Mirror' + REGISTRY = 'Registry' + + +class ConnectedRegistryActivationStatus(Enum): + ACTIVE = 'Active' + INACTIVE = 'Inactive' DEFAULT_GATEWAY_SCOPE = ['config/read', 'config/write', 'message/read', 'message/write'] @@ -68,18 +75,28 @@ def acr_connected_registry_create(cmd, # pylint: disable=too-many-locals, too-m ErrorResponseException = cmd.get_models('ErrorResponseException') parent = None + mode = mode.capitalize() if parent_name: try: parent = acr_connected_registry_show(cmd, client, parent_name, registry_name, resource_group_name) + connected_registry_list = list(client.list(resource_group_name, registry_name)) + family_tree, _ = _get_family_tree(connected_registry_list, None) except ErrorResponseException as ex: if ex.response.status_code == 404: raise CLIError("The parent connected registry '{}' could not be found.".format(parent_name)) raise CLIError(ex) - if parent.mode.lower() != ConnectedRegistryModes.REGISTRY.value and parent.mode.lower() != mode.lower(): + if parent.mode != ConnectedRegistryModes.REGISTRY.value and parent.mode != mode: raise CLIError("Can't create the registry '{}' with mode '{}' ".format(connected_registry_name, mode) + "when the connected registry parent '{}' mode is '{}'. ".format(parent_name, parent.mode) + "For more information on connected registries " + "please visit https://aka.ms/acr/connected-registry.") + msg = "Can't create the registry '{}'. The ancestor connected ".format(connected_registry_name) +\ + "registry activation status is not '{}'. ".format(ConnectedRegistryActivationStatus.ACTIVE.value) +\ + "Please install the parent connected registry and try again. For more information on connected " +\ + "registries, please visit https://aka.ms/acr/connected-registry." + _check_ancestors_are_active(family_tree, parent.id, msg) + _update_ancestor_permissions(cmd, family_tree, resource_group_name, registry_name, parent.id, + connected_registry_name, repositories, mode, False) if sync_token_name: sync_token_id = build_token_id(subscription_id, resource_group_name, registry_name, sync_token_name) @@ -202,7 +219,6 @@ def acr_connected_registry_delete(cmd, cleanup=False, yes=False, resource_group_name=None): - _, resource_group_name = validate_managed_registry( cmd, registry_name, resource_group_name) user_confirmation("Are you sure you want to delete the connected registry '{}' in '{}'?".format( @@ -220,8 +236,14 @@ def acr_connected_registry_delete(cmd, token_client = cf_acr_tokens(cmd.cli_ctx) scope_map_client = cf_acr_scope_maps(cmd.cli_ctx) + # Delete target sync scope map and token. acr_token_delete(cmd, token_client, registry_name, sync_token_name, yes, resource_group_name) acr_scope_map_delete(cmd, scope_map_client, registry_name, sync_scope_map_name, yes, resource_group_name) + # Cleanup gateway permissions from ancestors + connected_registry_list = list(client.list(resource_group_name, registry_name)) + family_tree, _ = _get_family_tree(connected_registry_list, None) + _update_ancestor_permissions(cmd, family_tree, resource_group_name, registry_name, + connected_registry.parent.id, connected_registry_name, remove_access=True) else: msg = "Connected registry successfully deleted. Please cleanup your sync tokens and scope maps. " + \ "Run the following commands for cleanup: \n\t" + \ @@ -271,19 +293,10 @@ def acr_connected_registry_list(cmd, else: result = [registry for registry in connected_registry_list if not registry.parent.id] elif parent_name: - family_tree = {} - for registry in connected_registry_list: - family_tree[registry.id] = { - "registry": registry, - "childs": [] - } - if registry.name == parent_name: - root_parent_id = registry.id - for registry in connected_registry_list: - parent_id = registry.parent.id - if parent_id and not parent_id.isspace(): - family_tree[parent_id]["childs"].append(registry.id) - result = _get_descendancy(family_tree, root_parent_id) + family_tree, parent = _get_family_tree(connected_registry_list, parent_name) + if parent is None: + raise CLIError("Parent connected registry '{}' doesn't exist.".format(parent_name)) + result = _get_descendants(family_tree, parent.id) else: result = connected_registry_list return result @@ -327,7 +340,7 @@ def _create_sync_token(cmd, mode): token_client = cf_acr_tokens(cmd.cli_ctx) - mode = mode.lower() + mode = mode.capitalize() if not any(option for option in ConnectedRegistryModes if option.value == mode): raise CLIError("usage error: --mode supports only 'registry' and 'mirror' values.") repository_actions_list = [[repo] + REPO_SCOPES_BY_MODE[mode] for repo in repositories] @@ -360,14 +373,34 @@ def _create_sync_token(cmd, raise CLIError(e) -def _get_descendancy(family_tree, parent_id): - childs = family_tree[parent_id]['childs'] +def _get_family_tree(connected_registry_list, target_connected_registry_name): + family_tree = {} + targetConnectedRegistry = None + # Populate the dictionary + for ConnectedRegistry in connected_registry_list: + family_tree[ConnectedRegistry.id] = { + "connectedRegistry": ConnectedRegistry, + "children": [] + } + if ConnectedRegistry.name == target_connected_registry_name: + targetConnectedRegistry = ConnectedRegistry + + # Populate Children dependencies + for ConnectedRegistry in connected_registry_list: + parent_id = ConnectedRegistry.parent.id + if parent_id and not parent_id.isspace(): + family_tree[parent_id]["children"].append(ConnectedRegistry.id) + return family_tree, targetConnectedRegistry + + +def _get_descendants(family_tree, parent_id): + children = family_tree[parent_id]['children'] result = [] - for child_id in childs: - result = [family_tree[child_id]["registry"]] - descendancy = _get_descendancy(family_tree, child_id) - if descendancy: - result.extend(descendancy) + for child_id in children: + result = [family_tree[child_id]["connectedRegistry"]] + descendants = _get_descendants(family_tree, child_id) + if descendants: + result.extend(descendants) return result @@ -440,3 +473,144 @@ def _get_install_info(cmd, "ACR_PARENT_PROTOCOL": "https" } # endregion + + +def _check_ancestors_are_active(family_tree, parent_id, msg): + while parent_id and not parent_id.isspace(): + ancestor = family_tree[parent_id]["connectedRegistry"] + if ancestor.activation.status != ConnectedRegistryActivationStatus.ACTIVE.value: + raise CLIError(msg) + parent_id = ancestor.parent.id + + +def _update_ancestor_permissions(cmd, + family_tree, + resource_group_name, + registry_name, + parent_id, + gateway, + repositories=None, + mode=None, + remove_access=False): + gateway_actions_list = [[gateway.lower()] + DEFAULT_GATEWAY_SCOPE] + if repositories is not None: + repository_actions_list = [[repo] + REPO_SCOPES_BY_MODE[mode] for repo in repositories] + repo_msg = ", ".join(repositories) + repo_msg = " and repo(s) '{}' {} permissions".format(repo_msg, mode) + if remove_access: + action_txt = "Removing" + add_actions_set = set() + remove_actions_set = set(parse_scope_map_actions(gateway_actions_list=gateway_actions_list)) + else: + action_txt = "Adding" + add_actions_set = set(parse_scope_map_actions(repository_actions_list, gateway_actions_list)) + remove_actions_set = set() + + while parent_id and not parent_id.isspace(): + ancestor = family_tree[parent_id]["connectedRegistry"] + msg = "{} '{}' gateway permissions{} to connected registry '{}' sync scope map.".format( + action_txt, gateway, repo_msg, ancestor.name) + _update_repo_permissions(cmd, resource_group_name, registry_name, + ancestor, add_actions_set, remove_actions_set, msg=msg) + parent_id = ancestor.parent.id + + +# region connected-registry repo update +def _update_repo_permissions(cmd, + resource_group_name, + registry_name, + connected_registry, + add_actions_set, + remove_actions_set, + msg=None, + description=None): + scope_map_client = cf_acr_scope_maps(cmd.cli_ctx) + sync_token = get_token_from_id(cmd, connected_registry.parent.sync_properties.token_id) + sync_scope_map = get_scope_map_from_id(cmd, sync_token.scope_map_id) + sync_scope_map_name = sync_scope_map.name + current_actions_set = set(sync_scope_map.actions) + final_actions_set = current_actions_set.union(add_actions_set).difference(remove_actions_set) + if final_actions_set == current_actions_set: + return None + current_actions = list(final_actions_set) + logger.warning(msg) + return scope_map_client.update( + resource_group_name, + registry_name, + sync_scope_map_name, + description, + current_actions + ) + + +def _get_scope_map_actions_set(repos, actions): + for i, repo_name in enumerate(repos): + repos[i] = [repo_name] + actions + return set(parse_scope_map_actions(repos)) + + +def acr_connected_registry_repo(cmd, + client, + connected_registry_name, + registry_name, + add_repos=None, + remove_repos=None, + resource_group_name=None): + if not (add_repos or remove_repos): + raise CLIError('No repository permissions to update.') + _, resource_group_name = validate_managed_registry( + cmd, registry_name, resource_group_name) + + add_repos_set = set(add_repos) if add_repos is not None else set() + remove_repos_set = set(remove_repos) if remove_repos is not None else set() + duplicate_repos = set.intersection(add_repos_set, remove_repos_set) + if duplicate_repos: + errors = sorted(map(lambda action: action[action.rfind('/') + 1:], duplicate_repos)) + raise CLIError( + 'Update ambiguity. Duplicate repository names were provided with ' + + '--add and --remove arguments.\n{}'.format(errors)) + + connected_registry_list = list(client.list(resource_group_name, registry_name)) + family_tree, target_connected_registry = _get_family_tree(connected_registry_list, connected_registry_name) + if target_connected_registry is None: + raise CLIError("Connected registry '{}' doesn't exist.".format(connected_registry_name)) + + # remove repo permissions from connected registry descendants. + remove_actions = REPO_SCOPES_BY_MODE[ConnectedRegistryModes.REGISTRY.value] + if remove_repos is not None: + remove_repos_txt = ", ".join(remove_repos) + remove_repos_set = _get_scope_map_actions_set(remove_repos, remove_actions) + descendants = _get_descendants(family_tree, target_connected_registry.id) + for connected_registry in descendants: + msg = "Removing '{}' permissions from {}".format(remove_repos_txt, connected_registry.name) + _update_repo_permissions(cmd, resource_group_name, registry_name, + connected_registry, set(), remove_repos_set, msg=msg) + else: + remove_repos_set = set() + + # add repo permissions to ancestors. + add_actions = REPO_SCOPES_BY_MODE[target_connected_registry.mode] + if add_repos is not None: + add_repos_txt = ", ".join(add_repos) + add_repos_set = _get_scope_map_actions_set(add_repos, add_actions) + parent_id = target_connected_registry.parent.id + while parent_id and not parent_id.isspace(): + connected_registry = family_tree[parent_id]["connectedRegistry"] + msg = "Adding '{}' permissions to {}".format(add_repos_txt, connected_registry.name) + _update_repo_permissions(cmd, resource_group_name, registry_name, + connected_registry, add_repos_set, set(), msg=msg) + parent_id = connected_registry.parent.id + else: + add_repos_set = set() + + # update target connected registry repo permissions. + if add_repos and remove_repos: + msg = "Adding '{}' and removing '{}' permissions in {}".format( + add_repos_txt, remove_repos_txt, target_connected_registry.name) + elif add_repos: + msg = "Adding '{}' permissions to {}".format(add_repos_txt, target_connected_registry.name) + else: + msg = "Removing '{}' permissions from {}".format(remove_repos_txt, target_connected_registry.name) + _update_repo_permissions(cmd, resource_group_name, registry_name, + target_connected_registry, add_repos_set, remove_repos_set, msg=msg) +# endregion