diff --git a/src/acrcache/HISTORY.rst b/src/acrcache/HISTORY.rst index ab7933b2e88..398f02859c3 100644 --- a/src/acrcache/HISTORY.rst +++ b/src/acrcache/HISTORY.rst @@ -2,6 +2,52 @@ Release History =============== +1.0.0c9 +++++++ +* **FEATURE**: added new artifact sync filtering options for cache rules + * `--tag`: filter tag by exact name +* **ENHANCEMENT**:Improved error messages for invalid parameter combinations + +1.0.0c8 +++++++ +* **BUGFIX**: Resolved issue with sync-referrers enabled without `--sync activesync` + * Added validation to ensure `--sync-referrers` can only be used with `--sync activesync` + * Ensured proper validation and assignment of managed identities in `az acr cache create` and `az acr cache update` commands + * Improved README documentation for clarity on parameter dependencies and usage examples + + +1.0.0c7 +++++++ +* **FEATURE**: Added `--assign-identity` parameter support for cache rules + * `az acr cache create --assign-identity` - Create cache rules with user-assigned managed identities + * `az acr cache update --assign-identity` - Update existing cache rules with managed identities + * Enables secure authentication for ACR-to-ACR caching across subscriptions within the same tenant + * Supports Azure resource ID format validation for managed identity resources +* **ENHANCEMENT**: Improved error handling and validation for identity parameters +* **TESTING**: Added comprehensive unit test coverage for identity processing functionality + +1.0.0c6 +++++++ +* **BREAKING**: Migrated to Container Registry SDK v2025-09-01-preview + * Updated SDK imports from v2025_07_01_preview to v2025_09_01_preview + * Updated SDK client factory to support new API version +* **ENHANCEMENT**: Standardized enum values for sync and referrer status + * Sync parameter now uses ActiveSync/PassiveSync values + * Referrer status now uses Enabled/Disabled values + * Added case-insensitive comparisons and improved None handling +* **REFACTOR**: Improved validation and state logic + * Refactored input validation logic in cache.py for sync/referrer options + * Modified CLI argument definitions in _params.py to reflect new enum values + * Enhanced error handling and parameter validation +* **DOCUMENTATION**: Updated help examples for clarity + * Rewrote help examples in _help.py for alignment with new conventions + * Improved CLI documentation and usage examples +* **TESTING**: Expanded test coverage + * Added comprehensive unit tests for cache operations and validation logic + * Updated test coverage to support the new API version + * Enhanced reliability testing under new SDK +* **COMPATIBILITY**: No breaking changes to CLI interface, only behavioral improvements + 1.0.0 ++++++ * Initial release. \ No newline at end of file diff --git a/src/acrcache/README.rst b/src/acrcache/README.rst index 033e38b4180..c94b2a49413 100644 --- a/src/acrcache/README.rst +++ b/src/acrcache/README.rst @@ -1,98 +1,119 @@ Microsoft Azure CLI 'acrcache' Extension ========================================== -## Overview -. +**Overview** + The `acrcache` extension adds support for managing Azure Container Registry (ACR) cache rules via the Azure CLI. -## Features +**Features** + +- Create, update, list, show, and sync cache rules for ACR. +**Parameters** + +- **Artifact Sync Modes**: Choose between passive and active synchronization of artifacts. + + - **--sync**: Set sync to `passivesync` (default) or `activesync`. + +- **Managed Identity Authentication**: Configure user-assigned managed identities for secure cross-registry authentication. + + - **--assign-identity**: Specify a user-assigned managed identity for authenticating with source registries. + +- **Configure artifact sync with flexible filters**: -- Create, update, list, show, and delete cache rules for ACR. -- Configure artifact sync with flexible filters: - **--platforms**: Filter which platforms to sync (e.g., `linux/amd64`, `linux/arm64`). - - **--sync-referrers**: Enable or disable syncing of referrers. - - **--include-artifact-types**: Specify artifact types to include in sync. + - **--sync-referrers**: Enable or disable syncing of referrers. Default value is disabled + - **--include-artifact-types**: Specify artifact types to include in sync. - **--exclude-artifact-types**: Specify artifact types to exclude from sync. - **--include-image-types**: Specify image types to include in sync. - **--exclude-image-types**: Specify image types to exclude from sync. -## Installation +- **Tag filtering options** (mutually exclusive): + + - **--tag**: Specify an exact tag name to sync. + - **--starts-with**: Sync tags that start with a specific prefix. + - **--ends-with**: Sync tags that end with a specific suffix. + - **--contains**: Sync tags that contain a specific substring. + +Installation +============== + +**Prerequisites** -### Prerequisites 1. Install Python (minimum supported version is 3.6; Python 3.12 is recommended) from http://python.org. -2. Fork and clone the required repo's + +2. Fork and clone the required repos - Azure CLI Repo : https://github.com/Azure/azure-cli - Azure CLI Extensions Repo : https://github.com/AzureCR/azure-cli-extensions. -- Note: ACR cache extension is in feature/artifactcache branch. +3. After forking `azure-cli`, follow the below commands to setup - After forking `azure-cli`, follow the below commands to setup + Clone your forked repository - # Clone your forked repository + - git clone `https://github.com//azure-cli.git` - git clone https://github.com//azure-cli.git + - cd azure-cli - cd azure-cli + Add the Azure/azure-cli repository as upstream - # Add the Azure/azure-cli repository as upstream + - git remote add upstream https://github.com/Azure/azure-cli.git - git remote add upstream https://github.com/Azure/azure-cli.git + - git fetch upstream - git fetch upstream + Reset the default dev branch to track dev branch of Azure/azure-cli so you can use it to track the latest azure-cli code. - # Reset the default dev branch to track dev branch of Azure/azure-cli so you can use it to track the latest azure-cli code. + - git branch dev --set-upstream-to upstream/dev - git branch dev --set-upstream-to upstream/dev + Develop with a new branch - # Develop with a new branch + - git checkout -b - git checkout -b Do the same for `azure-cli-extensions` except that the default branch for it is main, run git branch main --set-upstream-to upstream/main instead. Note: The ACR cache extension is in the `feature/artifactcache` branch of the `azure-cli-extensions` repository, do NOT merge changes into main. -3. Create a virtual environment in the directory that contains both your CLI and CLI extension clones +4. Create a virtual environment in the directory that contains both your CLI and CLI extension clones ```sh python -m venv .venv ``` -4. Activate the virtual environment + +5. Activate the virtual environment Windows CMD.exe: - `.venv\Scripts\activate.bat` + .venv\Scripts\activate.bat Windows Powershell: - `.venv\Scripts\activate.ps1` + `.venv\Scripts\activate.ps1` OSX/Linux (bash): - `source .venv/bin/activate` + `source .venv/bin/activate` -5. Install the required packages +6. Install the required packages Install python dependencies - ```sh - python -m pip install -U pip - ``` - Due to a known issue, the Azure CLI currently requires an older version of setuptools (70.0.0) and wheel (0.30.0) because newer versions are incompatible and may cause installation or build failures. For more details, see the related issue: https://github.com/Azure/azure-cli/issues/29467 - - pip install setuptools==70.0.0 + - python -m pip install -U pip + + Due to a known issue, the Azure CLI currently requires an older version of setuptools (70.0.0) and wheel (0.30.0) because newer versions are incompatible and may cause installation or build failures. + For more details, see the related issue: https://github.com/Azure/azure-cli/issues/29467 - pip install --force-reinstall wheel==0.30.0 + - pip install setuptools==70.0.0 - Install azdev - ```sh - pip install -U azdev - ``` + - pip install --force-reinstall wheel==0.30.0 + - Install azdev + + - pip install -U azdev + Setup azdev - azdev setup -c -r azure-cli-extensions/ + - azdev setup -c -r azure-cli-extensions/ + +7. Build extension - Build extension - azdev extension build acrcache + azdev extension build acrcache -Developing with the ACR Cache Extension +**Developing with the ACR Cache Extension** ========================================== Please write the description of changes which can be perceived by customers into HISTORY.rst. @@ -101,61 +122,71 @@ If you want to release a new extension version, please update the version in set If you make changes to the extension, you can test it by running: -azdev extension build "acrcache" + - azdev extension build "acrcache" -this will build your new changes into the extension package which you will see immediately. + **this will build your new changes into the extension package which you will see immediately.** -## Usage +After building the extension, if you don't see your changes immediately outputted in the CLI, you can run the following command to reinstall the newly built extension: -### List cache rules + - az extension remove --name acrcache + - az extension add --source "path\dist\acrcache.whl" -```sh -az acr cache list -r -``` -### Create a cache rule +**Usage** +============== -```sh -az acr cache create -r -n -s -t \ - --sync true --platforms linux/amd64,linux/arm64 --sync-referrers enabled \ - --include-artifact-types images,notary-project-signature --exclude-image-types -```sh -az acr cache list -r -az acr cache list -r -``` +**Cache Rules: create, update, list, show, and sync** -### Create a cache rule + see `az acr cache -h` for more information and examples. -```sh -az acr cache create -r -n -s -t \ - --sync true --platforms linux/amd64,linux/arm64 --sync-referrers enabled \ - --include-artifact-types images,notary-project-signature --exclude-image-types -``` +**Authentication Methods** -### Update a cache rule + **User-Assigned Managed Identity** -```sh -az acr cache update -r -n --platforms linux/amd64 --sync-referrers disabled \ - --include-artifact-types images --exclude-artifact-types -``` + The `--assign-identity` parameter enables secure authentication between Azure Container Registries using user-assigned managed identities. This is particularly useful for: -### Show a cache rule + - **Cross-subscription ACR caching**: Cache images from ACR to ACR within the same tenant + - **Enhanced security**: Eliminates the need for credential sets in many scenarios + - **Simplified authentication**: Uses Azure's managed identity infrastructure for secure access -```sh -az acr cache show -r -n -``` + Create cache rule with managed identity + ``` + az acr cache create -r -n -s .azurecr.io/ -t \ + --assign-identity /subscriptions//resourceGroups//providers/Microsoft.ManagedIdentity/userAssignedIdentities/ + ``` + + **Credentials** + + If `--assign-identity` is not specified in a cache rule command, the extension will fall back to using existing credentials configured for the target registry. This may include: + + - Admin user credentials + - Service principal credentials + + Create cache rule with credentials + ``` + az acr cache create -r -n -s .azurecr.io/ -t --credential-set + ``` -### Delete a cache rule +**Important Notes** +============== -```sh -az acr cache delete -r -n -``` + **Sync Modes** + - **PassiveSync** (default): Pull-through cache behavior - images are cached when pulled + - **ActiveSync**: Proactive synchronization - images are automatically pulled and cached + + **Parameter Dependencies** + - --sync-referrers enabled requires **--sync activesync** + - Artifact filtering parameters (--platforms, --include-artifact-types, etc.) require **--sync activesync** + - Tag filtering parameters (--tag, --starts-with, --ends-with, --contains) require **--sync activesync** + - Tag filtering options are mutually exclusive: use either **--tag** for exact match OR pattern-based filters (--starts-with, --ends-with, --contains) -## Minimum Azure CLI Version +**Minimum Azure CLI Version** +============== -This extension requires Azure CLI version **2.57.0** or higher. + This extension requires Azure CLI version **2.57.0** or higher. + You can check your Azure CLI version with: -## Documentation + az version For more details, see the official [Azure CLI documentation](https://learn.microsoft.com/cli/azure/acr). diff --git a/src/acrcache/azext_acrcache/_client_factory.py b/src/acrcache/azext_acrcache/_client_factory.py index af6722568cb..0bbd8921be9 100644 --- a/src/acrcache/azext_acrcache/_client_factory.py +++ b/src/acrcache/azext_acrcache/_client_factory.py @@ -14,4 +14,4 @@ def cf_acrreg(cli_ctx, *_): from azure.cli.core.commands.client_factory import get_mgmt_service_client, get_subscription_id from azext_acrcache.vendored_sdks.containerregistry.v2025_09_01_preview.generated.container_registry_management_client import ContainerRegistryManagementClient subscription_id = get_subscription_id(cli_ctx) - return get_mgmt_service_client(cli_ctx, ContainerRegistryManagementClient, subscription_id=subscription_id).registries + return get_mgmt_service_client(cli_ctx, ContainerRegistryManagementClient, subscription_id=subscription_id) diff --git a/src/acrcache/azext_acrcache/_help.py b/src/acrcache/azext_acrcache/_help.py index e545eedcf02..f24c00a989a 100644 --- a/src/acrcache/azext_acrcache/_help.py +++ b/src/acrcache/azext_acrcache/_help.py @@ -23,9 +23,6 @@ helps['acr cache list'] = """ type: command short-summary: List the cache rules in an Azure Container Registry. -long-summary: | - NOTE: The parameters --platforms, --sync-referrers, --include-artifact-types, and --exclude-artifact-types are not yet implemented. Using any of these parameters will return a 'not implemented' error message. - examples: - name: List the cache rules in an Azure Container Registry. text: az acr cache list -r myregistry @@ -34,13 +31,27 @@ helps['acr cache create'] = """ type: command short-summary: Create a cache rule. +parameters: + - name: --assign-identity + short-summary: Resource ID of the user-assigned managed identity. + long-summary: | + Resource ID of the user-assigned managed identity for authenticating with the ACR source registry. + Must be in the same tenant as both registries. + Format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} + - name: --sync-referrers + short-summary: Enable or disable sync referrers. + long-summary: Requires --sync activesync to be enabled. examples: - name: Create a cache rule without a credential set. text: az acr cache create -r myregistry -n MyRule -s docker.io/library/ubuntu -t ubuntu - name: Create a cache rule with a credential set. text: az acr cache create -r myregistry -n MyRule -s docker.io/library/ubuntu -t ubuntu -c MyCredSet + - name: Create a cache rule with a user-assigned managed identity (using test registry domain). + text: az acr cache create -r myregistry -n MyRule -s upstreamacrregistry.azurecr-test.io -t acr-to-acr-cacherule --assign-identity /subscriptions/{subscription-id}/resourceGroups/{resource-group}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identity-name} - name: Create a cache rule with artifact sync enabled and set a tag filter. text: az acr cache create -r myregistry -n MyRule -s docker.io/library/ubuntu -t ubuntu --sync activesync --starts-with v1 --ends-with beta + - name: Create a cache rule with artifact sync enabled and sync only a specific tag. + text: az acr cache create -r myregistry -n MyRule -s docker.io/library/nginx -t library/nginx --sync activesync --tag 8.0 - name: Create a cache rule with artifact sync enabled, set a tag filter, and specify platforms and sync referrers. text: az acr cache create -r myregistry -n MyRule -s docker.io/library/ubuntu -t ubuntu --sync activesync --starts-with v1 --ends-with beta --platforms linux/amd64,linux/arm64 --sync-referrers enabled - name: Create a cache rule with artifact sync enabled, set a tag filter, and specify platforms, sync referrers and artifact types to include. @@ -55,21 +66,32 @@ helps['acr cache update'] = """ type: command -short-summary: Update the credential set on a cache rule. -long-summary: | - NOTE: The parameters --platforms, --sync-referrers, --include-artifact-types, and --exclude-artifact-types are not yet implemented. Using any of these parameters will return a 'not implemented' error message. - +short-summary: Update a cache rule. +parameters: + - name: --assign-identity + short-summary: Resource ID of the user-assigned managed identity. + long-summary: | + Resource ID of the user-assigned managed identity for authenticating with the ACR source registry. + Must be in the same tenant as both registries. + Format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName} + - name: --sync-referrers + short-summary: Enable or disable syncing of referrers. + long-summary: Requires --sync activesync to be enabled. examples: - name: Change or add a credential set to an existing cache rule. text: az acr cache update -r myregistry -n MyRule -c NewCredSet - name: Remove a credential set from an existing cache rule. text: az acr cache update -r myregistry -n MyRule --remove-cred-set + - name: Update a cache rule with a user-assigned managed identity. + text: az acr cache update -r myregistry -n MyRule --assign-identity /subscriptions/{subscription-id}/resourceGroups/{resource-group}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identity-name} - name: Enable artifact sync and set a tag filter. text: az acr cache update -r myregistry -n MyRule --sync activesync --starts-with v1 --ends-with beta - name: Enable artifact sync, set a tag filter, and specify platforms and sync referrers. text: az acr cache update -r myregistry -n MyRule --sync activesync --starts-with v1 --ends-with beta --platforms linux/amd64,linux/arm64 --sync-referrers enabled - name: Enable artifact sync, set a tag filter, and specify platforms, sync referrers and artifact types to include. text: az acr cache update -r myregistry -n MyRule --sync activesync --starts-with v1 --ends-with beta --platforms linux/amd64,linux/arm64 --sync-referrers enabled --include-artifact-types images,notary-project-signature + - name: Update cache rule to sync only a specific tag. + text: az acr cache update -r myregistry -n MyRule --sync activesync --tag latest - name: Enable artifact sync, set a tag filter, and specify platforms, sync referrers and artifact types to exclude. text: az acr cache update -r myregistry -n MyRule --sync activesync --starts-with v1 --ends-with beta --platforms linux/amd64,linux/arm64 --sync-referrers enabled --exclude-artifact-types application/vnd.aquasec.trivy.vulnerability.report """ diff --git a/src/acrcache/azext_acrcache/_params.py b/src/acrcache/azext_acrcache/_params.py index e8902a98607..6cdf57beda1 100644 --- a/src/acrcache/azext_acrcache/_params.py +++ b/src/acrcache/azext_acrcache/_params.py @@ -12,6 +12,7 @@ def load_arguments(self, _): c.argument('registry_name', options_list=['--registry', '-r'], help='The name of the container registry. It should be specified in lower case. You can configure the default registry name using `az configure --defaults acr=`') c.argument('name', options_list=['--name', '-n'], help='The name of the cache rule.') c.argument('cred_set', options_list=['--cred-set', '-c'], help='The name of the credential set.') + c.argument('assign_identity', options_list=['--assign-identity', '-i'], help='Resource ID of the user-assigned managed identity for authenticating with the ACR source registry. Must be in the same tenant as both registries. Format: /subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{identityName}') c.argument('source_repo', options_list=['--source-repo', '-s'], help="The full source repository path such as 'docker.io/library/ubuntu'.") c.argument('target_repo', options_list=['--target-repo', '-t'], help="The target repository namespace such as 'ubuntu'.") c.argument('remove_cred_set', action="store_true", help='Optional boolean indicating whether to remove the credential set from the cache rule. False by default.') @@ -21,6 +22,7 @@ def load_arguments(self, _): c.argument('starts_with', help='Only sync the tags that start with the specified string.') c.argument('ends_with', help='Only sync the tags that end with the specified string.') c.argument('contains', help='Only sync the tags that contain the specified string.') + c.argument('tag', help='Only sync the artifacts with this exact tag name. This is an exact match filter.') c.argument('image', help='The name of the tag you want to sync immediately. Can only be used if artifact sync is enabled and the tag is within any specified tag filter.') c.argument('platforms', help='A list of platforms to filter the artifacts by. e.g. amd64/linux') c.argument('sync_referrers', arg_type=get_enum_type(['Enabled', 'Disabled']), help='Enable or disable syncing the referrers of the artifacts. Default is Disabled.') diff --git a/src/acrcache/azext_acrcache/cache.py b/src/acrcache/azext_acrcache/cache.py index 754512e30ae..469476d5ed2 100644 --- a/src/acrcache/azext_acrcache/cache.py +++ b/src/acrcache/azext_acrcache/cache.py @@ -4,33 +4,40 @@ # -------------------------------------------------------------------------------------------- # pylint: disable=line-too-long +import re from azure.cli.core.util import user_confirmation from knack.util import CLIError from azure.core.serialization import NULL as AzureCoreNull from azure.cli.command_modules.acr._utils import get_resource_group_name_by_registry_name, get_registry_by_name +from azure.mgmt.core.tools import parse_resource_id, is_valid_resource_id from .vendored_sdks.containerregistry.v2025_09_01_preview.generated.container_registry_management_client.models._models import ( CacheRule, CacheRuleProperties, CacheRuleUpdateParameters, CacheRuleUpdateProperties, ImportSource, ImportImageParameters, - PlatformFilter, ArtifactTypeFilter, TagFilter, ArtifactSyncFilterProperties + PlatformFilter, ArtifactTypeFilter, TagFilter, ArtifactSyncFilterProperties, + IdentityProperties, UserIdentityProperties ) -def _create_kql(starts_with=None, ends_with=None, contains=None): - if not starts_with and not ends_with and not contains: +# Constants for managed identity resource validation +MANAGED_IDENTITY_RESOURCE_PROVIDER = "Microsoft.ManagedIdentity" +USER_ASSIGNED_IDENTITY_RESOURCE_TYPE = "userAssignedIdentities" + +def _create_kql(starts_with=None, ends_with=None, contains=None, tag=None): + if not starts_with and not ends_with and not contains and not tag: return "Tags" query = "Tags | where " + conditions = [] if starts_with: - query += f"Name startswith '{starts_with}'" - if ends_with or contains: - query += " and " + conditions.append(f"Name startswith '{starts_with}'") if ends_with: - query += f"Name endswith '{ends_with}'" - if contains: - query += " and " + conditions.append(f"Name endswith '{ends_with}'") if contains: - query += f"Name contains '{contains}'" + conditions.append(f"Name contains '{contains}'") + if tag: + conditions.append(f"Name == '{tag}'") + query += " and ".join(conditions) return query @@ -38,6 +45,7 @@ def _separate_params(query): starts_with = None ends_with = None contains = None + tag = None if "Name startswith" in query: starts_with = query.split("Name startswith")[1].split("'")[1] @@ -47,9 +55,55 @@ def _separate_params(query): if "Name contains" in query: contains = query.split("Name contains")[1].split("'")[1] - - return starts_with, ends_with, contains - + + if "Name ==" in query: + tag = query.split("Name ==")[1].split("'")[1] + + return starts_with, ends_with, contains, tag + +def process_assign_identity_parameter(assign_identity: str) -> IdentityProperties: + """Process assign identity parameter and return IdentityProperties object. + + :param assign_identity: User-assigned managed identity resource ID + :return: IdentityProperties object or None + """ + + if not assign_identity: + return None + + if not is_valid_user_assigned_managed_identity_resource_id(assign_identity): + raise CLIError(f"Invalid user-assigned managed identity resource ID: {assign_identity}") + + identity_properties = IdentityProperties( + type="UserAssigned", + user_assigned_identities={ + assign_identity: UserIdentityProperties() + } + ) + return identity_properties + +def is_valid_user_assigned_managed_identity_resource_id(resource_id): + """ + Validate user-assigned managed identity resource ID using Azure's built-in utilities. + + :param resource_id: Resource ID to validate + :return: True if valid, False otherwise + """ + if not is_valid_resource_id(resource_id): + return False + + try: + parsed = parse_resource_id(resource_id) + # Ensure it's specifically a Microsoft.ManagedIdentity userAssignedIdentities resource + return ( + parsed.get("namespace") == MANAGED_IDENTITY_RESOURCE_PROVIDER and + ( + parsed.get("type") == USER_ASSIGNED_IDENTITY_RESOURCE_TYPE or + parsed.get("resource_type") == USER_ASSIGNED_IDENTITY_RESOURCE_TYPE + ) + ) + except Exception: + return False def acr_cache_show(cmd, client, @@ -96,10 +150,12 @@ def acr_cache_create(cmd, target_repo, resource_group_name=None, cred_set=None, + assign_identity=None, sync=False, starts_with=None, ends_with=None, contains=None, + tag=None, dry_run=False, yes=False, platforms=None, @@ -117,7 +173,7 @@ def acr_cache_create(cmd, rg = resource_group_name else: #extract resource group from registry id - import re + match = re.search(r'/resourceGroups/([^/]+)/', registry.id) rg = match.group(1) if match else None @@ -127,8 +183,10 @@ def acr_cache_create(cmd, sync_str = sync if sync else None sync_referrers_str = "Enabled" if sync_referrers and sync_referrers.lower() == 'enabled' else "Disabled" - if sync_referrers and sync_referrers.lower() == 'enabled' and sync and sync.lower() != 'activesync': - raise CLIError("Syncing referrers requires sync to be set to 'activesync'. Please update your cache rule configuration.") + # Validate sync_referrers requires activesync - check both when sync is provided and when it's not + if sync_referrers and sync_referrers.lower() == 'enabled': + if not sync or sync.lower() != 'activesync': + raise CLIError("Syncing referrers requires sync to be set to 'activesync'. Please update your cache rule configuration.") if include_artifact_types and exclude_artifact_types: raise CLIError("You cannot specify both include_artifact_types and exclude_artifact_types. Please choose one.") @@ -139,16 +197,18 @@ def acr_cache_create(cmd, #validate that filter parameters require sync to be enabled if sync and sync.lower() != 'activesync' and (include_artifact_types or exclude_artifact_types or include_image_types or exclude_image_types or - platforms or starts_with or ends_with or contains): + platforms or starts_with or ends_with or contains or tag): raise CLIError("Artifact sync filters (--include-artifact-types, --exclude-artifact-types, " "--include-image-types, --exclude-image-types, --platforms, " - "--starts-with, --ends-with, --contains) require --sync activesync.") + "--starts-with, --ends-with, --contains, --tag) require --sync activesync.") cred_set_id = AzureCoreNull if not cred_set else f'{registry.id}/credentialSets/{cred_set}' - tag = None + identity_properties = process_assign_identity_parameter(assign_identity) + + # Validate that source_repo doesn't contain tags. Users must use --tag parameter if ':' in source_repo: - source_repo, tag = source_repo.rsplit(':', 1) + raise CLIError("Source repository should not include a tag. Please use the --tag parameter to specify tag for filtering.") #create artifact sync filters object artifact_sync_filters = None @@ -189,8 +249,10 @@ def acr_cache_create(cmd, values=exclude_image_list ) - kql_str = f"Tags | where Name == '{tag}'" if tag is not None else _create_kql(starts_with, ends_with, contains) - if sync and not kql_str: + # Generate KQL query for tag filtering + if starts_with or ends_with or contains or tag: + kql_str = _create_kql(starts_with, ends_with, contains, tag) + else: kql_str = "Tags" artifact_sync_filters["tags"] = TagFilter( @@ -213,10 +275,11 @@ def acr_cache_create(cmd, # Create cache rule with properties cache_rule = CacheRule( name=name, - properties=properties + properties=properties, + identity=identity_properties ) - if tag is None and sync and not dry_run: + if not (starts_with or ends_with or contains or tag) and sync and not dry_run: user_confirmation("Your cache rule has Artifact Sync enabled and will automatically import tags into your registry. This may incur additional storage charges. Run with the dry-run flag for details. Continue?", yes) return client.begin_create( @@ -233,11 +296,13 @@ def acr_cache_update_custom(cmd, name, resource_group_name=None, cred_set=None, + assign_identity=None, remove_cred_set=False, sync=None, starts_with=None, ends_with=None, contains=None, + tag=None, yes=False, platforms=None, sync_referrers=None, @@ -261,9 +326,10 @@ def acr_cache_update_custom(cmd, #check if activesync is enabled #check both existing sync mode (when not changing sync) AND new sync value (when updating sync) - isActiveSync = (sync is None and sync_mode and sync_mode.lower() == 'activesync') or (sync and sync.lower() == 'activesync') + is_active_sync = (sync is None and sync_mode and sync_mode.lower() == 'activesync') or (sync and sync.lower() == 'activesync') - if sync_referrers and sync_referrers.lower() == 'enabled' and not isActiveSync and sync and sync.lower() != 'activesync': + # Validate sync_referrers requires activesync + if sync_referrers and sync_referrers.lower() == 'enabled' and not is_active_sync: raise CLIError("Syncing referrers requires sync to be set to 'activesync'. Please update your cache rule configuration.") # Warn if mutually exclusive parameters are provided @@ -279,7 +345,7 @@ def acr_cache_update_custom(cmd, updated_image_types = None #preserve old artifact sync filters - preserve_filters = (properties.artifact_sync_filters is not None and isActiveSync) + preserve_filters = (properties.artifact_sync_filters is not None and is_active_sync) if preserve_filters: # Copy tag filters If no new filters are provided @@ -308,6 +374,9 @@ def acr_cache_update_custom(cmd, if cred_set is None and not remove_cred_set: cred_set_id = AzureCoreNull + # Process identity parameter + identity_properties = process_assign_identity_parameter(assign_identity) + # Handle artifact sync status - only change if explicitly provided if sync is not None: sync_mode = "ActiveSync" if sync.lower() == 'activesync' else "PassiveSync" @@ -317,11 +386,11 @@ def acr_cache_update_custom(cmd, #update artifact sync filters object updated_artifact_sync_filters = None - if isActiveSync: - if starts_with or ends_with or contains: + if is_active_sync: + if starts_with or ends_with or contains or tag: updated_tags = TagFilter( type="KQL", - query=_create_kql(starts_with, ends_with, contains) + query=_create_kql(starts_with, ends_with, contains, tag) ) if platforms: @@ -373,19 +442,23 @@ def acr_cache_update_custom(cmd, artifact_sync_filters=updated_artifact_sync_filters ) - if isActiveSync: + if is_active_sync: user_confirmation("Your cache rule has Artifact Sync enabled and will automatically import tags into your registry. This may incur additional storage charges. Continue?", yes) if remove_cred_set: return client.begin_create(resource_group_name=rg, registry_name=registry_name, cache_rule_name=name, - cache_rule_create_parameters=CacheRuleUpdateParameters(properties=updated_properties)) + cache_rule_create_parameters=CacheRuleUpdateParameters( + properties=updated_properties, + identity=identity_properties)) return client.begin_update(resource_group_name=rg, registry_name=registry_name, cache_rule_name=name, - cache_rule_update_parameters=CacheRuleUpdateParameters(properties=updated_properties)) + cache_rule_update_parameters=CacheRuleUpdateParameters( + properties=updated_properties, + identity=identity_properties)) def acr_cache_sync(cmd, @@ -398,12 +471,13 @@ def acr_cache_sync(cmd, rg = get_resource_group_name_by_registry_name(cmd.cli_ctx, registry_name, resource_group_name) rule = client.cache_rules.get(resource_group_name=rg, - registry_name=registry_name, - cache_rule_name=name) + registry_name=registry_name, + cache_rule_name=name) + tag = image rule_id = rule.id - source_repo = rule.source_repository - target_repo = rule.target_repository + source_repo = rule.properties.source_repository + target_repo = rule.properties.target_repository source_image_str = source_repo[source_repo.find('/') + 1:] + ":" + tag import_source = ImportSource(source_image=source_image_str, @@ -415,6 +489,6 @@ def acr_cache_sync(cmd, target_tags=[target_repo + ":" + tag]) return client.registries.begin_import_image(resource_group_name=rg, - registry_name=registry_name, - parameters=params) + registry_name=registry_name, + parameters=params) diff --git a/src/acrcache/azext_acrcache/tests/latest/test_cache.py b/src/acrcache/azext_acrcache/tests/latest/test_cache.py index 853591b547c..e5e7500b06a 100644 --- a/src/acrcache/azext_acrcache/tests/latest/test_cache.py +++ b/src/acrcache/azext_acrcache/tests/latest/test_cache.py @@ -15,22 +15,47 @@ def test_create_kql_basic(self): self.assertEqual(cache._create_kql(starts_with="foo"), "Tags | where Name startswith 'foo'") self.assertEqual(cache._create_kql(ends_with="bar"), "Tags | where Name endswith 'bar'") self.assertEqual(cache._create_kql(contains="baz"), "Tags | where Name contains 'baz'") + self.assertEqual(cache._create_kql(tag="exact"), "Tags | where Name == 'exact'") self.assertEqual(cache._create_kql(starts_with="foo", ends_with="bar"), "Tags | where Name startswith 'foo' and Name endswith 'bar'") self.assertEqual(cache._create_kql(starts_with="foo", contains="baz"), "Tags | where Name startswith 'foo' and Name contains 'baz'") self.assertEqual(cache._create_kql(ends_with="bar", contains="baz"), "Tags | where Name endswith 'bar' and Name contains 'baz'") + # Test that specific tag is mutually exclusive with other filters + with self.assertRaises(CLIError): + cache._create_kql(tag="exact", starts_with="foo") + with self.assertRaises(CLIError): + cache._create_kql(tag="exact", ends_with="bar") + with self.assertRaises(CLIError): + cache._create_kql(tag="exact", contains="baz") + with self.assertRaises(CLIError): + cache._create_kql(tag="exact", starts_with="foo", ends_with="bar") + with self.assertRaises(CLIError): + cache._create_kql(tag="exact", starts_with="foo", contains="baz") + with self.assertRaises(CLIError): + cache._create_kql(tag="exact", ends_with="bar", contains="baz") + with self.assertRaises(CLIError): + cache._create_kql(tag="exact", starts_with="foo", ends_with="bar", contains="baz") self.assertEqual(cache._create_kql(starts_with="foo", ends_with="bar", contains="baz"), "Tags | where Name startswith 'foo' and Name endswith 'bar' and Name contains 'baz'") def test_separate_params(self): """Test parameter extraction from KQL queries""" q = "Tags | where Name startswith 'foo' and Name endswith 'bar' and Name contains 'baz'" - starts_with, ends_with, contains = cache._separate_params(q) + starts_with, ends_with, contains, tag = cache._separate_params(q) self.assertEqual(starts_with, "foo") self.assertEqual(ends_with, "bar") self.assertEqual(contains, "baz") + self.assertIsNone(tag) + + # Test exact tag match + q_tag = "Tags | where Name == 'exact'" + starts_with, ends_with, contains, tag = cache._separate_params(q_tag) + self.assertIsNone(starts_with) + self.assertIsNone(ends_with) + self.assertIsNone(contains) + self.assertEqual(tag, "exact") class TestCacheOperations(unittest.TestCase): @@ -138,6 +163,22 @@ def test_acr_cache_create_sync_referrers_requires_activesync(self, mock_get_regi sync_referrers="enabled" ) + @mock.patch('azext_acrcache.cache.get_registry_by_name') + def test_acr_cache_create_sync_referrers_without_sync_parameter_fails(self, mock_get_registry): + """Test that sync referrers fails when no sync parameter is provided""" + mock_registry = mock.Mock() + mock_registry.id = "/subscriptions/xxx/resourceGroups/rg1/providers/xxx" + mock_get_registry.return_value = (mock_registry, None) + + with self.assertRaises(CLIError) as cm: + cache.acr_cache_create( + self.cmd, self.client, "mockRegistry", "mockCacheRule1", "mockRepo1", "mockRepo2", + resource_group_name="mockrg", + sync_referrers="enabled" # No sync parameter provided + ) + + self.assertIn("Syncing referrers requires sync to be set to 'activesync'", str(cm.exception)) + class TestCacheUpdateValidation(unittest.TestCase): """Test cache update validation logic""" @@ -219,5 +260,215 @@ def test_acr_cache_sync_calls_import_image(self, mock_get_rg): self.assertEqual(params.target_tags, ["repo/target:tag1"]) self.assertEqual(params.source.cache_rule_resource_id, "ruleid") +class TestIdentityProcessing(unittest.TestCase): + """Test identity parameter processing functionality""" + + def test_process_assign_identity_parameter_none(self): + """Test process_assign_identity_parameter returns None when no identity provided""" + result = cache.process_assign_identity_parameter(None) + self.assertIsNone(result) + + result = cache.process_assign_identity_parameter("") + self.assertIsNone(result) + + def test_process_assign_identity_parameter_valid(self): + """Test process_assign_identity_parameter with valid resource ID""" + resource_id = "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myIdentity" + + result = cache.process_assign_identity_parameter(resource_id) + + self.assertIsNotNone(result) + self.assertEqual(result.type, "UserAssigned") + self.assertIn(resource_id, result.user_assigned_identities) + self.assertIsInstance(result.user_assigned_identities[resource_id], cache.UserIdentityProperties) + + def test_process_assign_identity_parameter_invalid(self): + """Test process_assign_identity_parameter raises error for invalid resource ID""" + invalid_ids = [ + "invalid-resource-id", + "/resourceGroups/myRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myIdentity", + "subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myIdentity" + ] + + for invalid_id in invalid_ids: + with self.assertRaises(CLIError) as context: + cache.process_assign_identity_parameter(invalid_id) + self.assertIn("Invalid user-assigned managed identity resource ID", str(context.exception)) + + def test_is_valid_user_assigned_managed_identity_resource_id(self): + """Test resource ID validation function""" + # Valid resource IDs + valid_ids = [ + "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myIdentity", + "/subscriptions/abcdefab-1234-5678-9012-123456789012/resourceGroups/test-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/test-identity" + ] + + for valid_id in valid_ids: + self.assertTrue(cache.is_valid_user_assigned_managed_identity_resource_id(valid_id)) + + # Invalid resource IDs + invalid_ids = [ + "invalid-resource-id", + "/resourceGroups/myRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myIdentity", + "subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myIdentity", + "", + # Wrong resource provider + "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.Storage/storageAccounts/mystorage", + # Wrong resource type + "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.ManagedIdentity/systemAssignedIdentities/myIdentity", + ] + + for invalid_id in invalid_ids: + self.assertFalse(cache.is_valid_user_assigned_managed_identity_resource_id(invalid_id)) + + # Edge cases that are technically invalid but accepted by Azure's parsing utilities + potentially_invalid_but_accepted_ids = [ + "/subscriptions/abcdefgh-1234-5678-9012-123456789012/resourceGroups/myRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myIdentity", # contains g,h + "/subscriptions/notauuid/resourceGroups/myRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myIdentity", # not UUID format + ] + + # These are accepted by Azure's parsing utilities (which is by design) + for accepted_id in potentially_invalid_but_accepted_ids: + result = cache.is_valid_user_assigned_managed_identity_resource_id(accepted_id) + + + +class TestCacheCreateWithIdentity(unittest.TestCase): + """Test cache creation with identity parameter""" + + def setUp(self): + """Set up test fixtures""" + self.cmd = mock.Mock() + self.cmd.cli_ctx = mock.Mock() + self.client = mock.Mock() + self.valid_identity_id = "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myIdentity" + + @mock.patch('azext_acrcache.cache.get_registry_by_name') + @mock.patch('azext_acrcache.cache.user_confirmation') + def test_acr_cache_create_with_valid_identity(self, mock_confirmation, mock_get_registry): + """Test cache creation with valid identity""" + mock_registry = mock.Mock() + mock_registry.id = "/subscriptions/xxx/resourceGroups/rg1/providers/Microsoft.ContainerRegistry/registries/registry1" + mock_get_registry.return_value = (mock_registry, None) + + cache.acr_cache_create( + self.cmd, self.client, "mockRegistry", "mockCacheRule", "source/repo", "target/repo", + resource_group_name="mockrg", + assign_identity=self.valid_identity_id, + sync="activesync", + yes=True + ) + + # Verify client.begin_create was called + self.client.begin_create.assert_called_once() + + # Extract the cache rule from the call + call_args = self.client.begin_create.call_args[1] + cache_rule = call_args['cache_rule_create_parameters'] + + # Verify identity was set + self.assertIsNotNone(cache_rule.identity) + self.assertEqual(cache_rule.identity.type, "UserAssigned") + self.assertIn(self.valid_identity_id, cache_rule.identity.user_assigned_identities) + + @mock.patch('azext_acrcache.cache.get_registry_by_name') + def test_acr_cache_create_with_invalid_identity(self, mock_get_registry): + """Test cache creation with invalid identity raises error""" + mock_registry = mock.Mock() + mock_registry.id = "/subscriptions/xxx/resourceGroups/rg1/providers/Microsoft.ContainerRegistry/registries/registry1" + mock_get_registry.return_value = (mock_registry, None) + + with self.assertRaises(CLIError): + cache.acr_cache_create( + self.cmd, self.client, "mockRegistry", "mockCacheRule", "source/repo", "target/repo", + resource_group_name="mockrg", + assign_identity="invalid-identity-id" + ) + + @mock.patch('azext_acrcache.cache.get_registry_by_name') + @mock.patch('azext_acrcache.cache.user_confirmation') + def test_acr_cache_create_without_identity(self, mock_confirmation, mock_get_registry): + """Test cache creation without identity works normally""" + mock_registry = mock.Mock() + mock_registry.id = "/subscriptions/xxx/resourceGroups/rg1/providers/Microsoft.ContainerRegistry/registries/registry1" + mock_get_registry.return_value = (mock_registry, None) + + cache.acr_cache_create( + self.cmd, self.client, "mockRegistry", "mockCacheRule", "source/repo", "target/repo", + resource_group_name="mockrg", + sync="activesync", + yes=True + ) + + # Verify client.begin_create was called + self.client.begin_create.assert_called_once() + + # Extract the cache rule from the call + call_args = self.client.begin_create.call_args[1] + cache_rule = call_args['cache_rule_create_parameters'] + + # Verify identity is None + self.assertIsNone(cache_rule.identity) + +class TestCacheUpdateWithIdentity(unittest.TestCase): + """Test cache update with identity parameter""" + + def setUp(self): + """Set up test fixtures""" + self.cmd = mock.Mock() + self.cmd.cli_ctx = mock.Mock() + self.client = mock.Mock() + self.valid_identity_id = "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/myIdentity" + + # Set up mock cache rule + self.dummy_rule = mock.Mock() + self.dummy_rule.properties = mock.Mock() + self.dummy_rule.properties.sync_mode = "ActiveSync" + self.dummy_rule.properties.sync_referrers = "Disabled" + self.dummy_rule.properties.artifact_sync_filters = None + self.dummy_rule.properties.credential_set_resource_id = None + + @mock.patch('azext_acrcache.cache.get_registry_by_name') + @mock.patch('azext_acrcache.cache.user_confirmation') + def test_acr_cache_update_with_valid_identity(self, mock_confirmation, mock_get_registry): + """Test cache update with valid identity""" + mock_registry = mock.Mock() + mock_registry.id = "/subscriptions/xxx/resourceGroups/rg1/providers/Microsoft.ContainerRegistry/registries/registry1" + mock_get_registry.return_value = (mock_registry, "mockrg") + self.client.get.return_value = self.dummy_rule + + cache.acr_cache_update_custom( + self.cmd, self.client, "mockRegistry", "mockCacheRule", + assign_identity=self.valid_identity_id, + yes=True + ) + + # Verify client.begin_update was called + self.client.begin_update.assert_called_once() + + # Extract the update parameters from the call + call_args = self.client.begin_update.call_args[1] + update_params = call_args['cache_rule_update_parameters'] + + # Verify identity was set + self.assertIsNotNone(update_params.identity) + self.assertEqual(update_params.identity.type, "UserAssigned") + self.assertIn(self.valid_identity_id, update_params.identity.user_assigned_identities) + + @mock.patch('azext_acrcache.cache.get_registry_by_name') + def test_acr_cache_update_with_invalid_identity(self, mock_get_registry): + """Test cache update with invalid identity raises error""" + mock_registry = mock.Mock() + mock_registry.id = "/subscriptions/xxx/resourceGroups/rg1/providers/Microsoft.ContainerRegistry/registries/registry1" + mock_get_registry.return_value = (mock_registry, "mockrg") + self.client.get.return_value = self.dummy_rule + + with self.assertRaises(CLIError): + cache.acr_cache_update_custom( + self.cmd, self.client, "mockRegistry", "mockCacheRule", + assign_identity="invalid-identity-id" + ) + + if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/src/acrcache/setup.py b/src/acrcache/setup.py index 28cff359c5e..e15b61a4cf7 100644 --- a/src/acrcache/setup.py +++ b/src/acrcache/setup.py @@ -14,7 +14,7 @@ logger.warn("Wheel is not available, disabling bdist_wheel hook") # HISTORY.rst entry. -VERSION = '1.0.0c6' +VERSION = '1.0.0c9' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers