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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,28 @@ acr purge \
--include-locked
```

#### ABAC (Attribute-Based Access Control) registries

Registries with ABAC enabled use repository-scoped permissions instead of registry-wide roles. When using `acr purge` with an ABAC registry, keep the following in mind:

**Required permissions:**
- **Catalog listing:** The user must have permission to list repositories (e.g., the `Container Registry Repository Catalog Lister` role or equivalent `registry:catalog:*` scope).
- **Repository access:** The user needs the `Container Registry Repository Contributor` role for deletes, which can be scoped to specific repositories using ABAC conditions.

**Partial access behavior:**

If a broad `--filter` matches repositories that the user does not have permission to purge, the command will stop at the first unauthorized repository and report:
- Which repository failed due to insufficient permissions
- Which repositories were already successfully purged
- Which repositories were not yet processed

To avoid this, use a more specific `--filter` to target only repositories you have access to.

**Batch size (environment variable):**

ABAC registries process repositories in batches, where each batch shares a single token scope. Token refresh happens dynamically when API calls detect token expiration. The batch size can be configured via the `ABAC_BATCH_SIZE` environment variable (default: 10).


### Integration with ACR Tasks

To run a locally built version of the ACR-CLI using ACR Tasks follow these steps:
Expand Down
164 changes: 137 additions & 27 deletions cmd/acr/purge.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@ package main

import (
"context"
"errors"
"fmt"
"net/http"
"os"
"runtime"
"sort"
"strconv"
"strings"
"time"

"github.com/Azure/acr-cli/acr"
"github.com/Azure/acr-cli/cmd/repository"
"github.com/Azure/acr-cli/internal/api"
"github.com/Azure/acr-cli/internal/worker"
"github.com/Azure/go-autorest/autorest"
"github.com/dlclark/regexp2"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -89,6 +93,7 @@ type purgeParameters struct {
includeLocked bool
concurrency int
repoPageSize int32
verbose bool
}

// newPurgeCmd defines the purge command.
Expand Down Expand Up @@ -178,9 +183,9 @@ func newPurgeCmd(rootParams *rootParameters) *cobra.Command {
// Combine flags for clarity - these are mutually exclusive
supportUntaggedCleanup := purgeParams.untagged || purgeParams.untaggedOnly

deletedTagsCount, deletedManifestsCount, err := purge(ctx, acrClient, loginURL, repoParallelism, agoDuration, purgeParams.keep, purgeParams.filterTimeout, supportUntaggedCleanup, purgeParams.untaggedOnly, tagFilters, purgeParams.dryRun, purgeParams.includeLocked)
deletedTagsCount, deletedManifestsCount, err := purge(ctx, acrClient, loginURL, repoParallelism, agoDuration, purgeParams.keep, purgeParams.filterTimeout, supportUntaggedCleanup, purgeParams.untaggedOnly, tagFilters, purgeParams.dryRun, purgeParams.includeLocked, purgeParams.verbose)

if err != nil {
if err != nil && !strings.Contains(err.Error(), "insufficient permissions") {
fmt.Printf("Failed to complete purge: %v \n", err)
}

Expand Down Expand Up @@ -208,6 +213,7 @@ func newPurgeCmd(rootParams *rootParameters) *cobra.Command {
cmd.Flags().Int64Var(&purgeParams.filterTimeout, "filter-timeout-seconds", defaultRegexpMatchTimeoutSeconds, "This limits the evaluation of the regex filter, and will return a timeout error if this duration is exceeded during a single evaluation. If written incorrectly a regexp filter with backtracking can result in an infinite loop.")
cmd.Flags().IntVar(&purgeParams.concurrency, "concurrency", defaultPoolSize, concurrencyDescription)
cmd.Flags().Int32Var(&purgeParams.repoPageSize, "repository-page-size", defaultRepoPageSize, repoPageSizeDescription)
cmd.Flags().BoolVar(&purgeParams.verbose, "verbose", false, "Enable verbose output including detailed repository names during ABAC token operations")
cmd.Flags().BoolP("help", "h", false, "Print usage")
// Make filter and ago conditionally required based on untagged-only flag
cmd.MarkFlagsOneRequired("filter", "untagged-only")
Expand All @@ -226,36 +232,92 @@ func purge(ctx context.Context,
untaggedOnly bool,
tagFilters map[string]string,
dryRun bool,
includeLocked bool) (deletedTagsCount int, deletedManifestsCount int, err error) {

// In order to print a summary of the deleted tags/manifests the counters get updated everytime a repo is purged.
for repoName, tagRegex := range tagFilters {
var singleDeletedTagsCount int
var manifestToTagsCountMap map[string]int

// Handle tag deletion based on mode
if untaggedOnly {
// Initialize empty map for untagged-only mode (no tag deletion)
manifestToTagsCountMap = make(map[string]int)
} else {
// Standard mode: delete matching tags first
singleDeletedTagsCount, manifestToTagsCountMap, err = purgeTags(ctx, acrClient, repoParallelism, loginURL, repoName, agoDuration, tagRegex, keep, filterTimeout, dryRun, includeLocked)
if err != nil {
return deletedTagsCount, deletedManifestsCount, fmt.Errorf("failed to purge tags: %w", err)
includeLocked bool,
verbose bool) (deletedTagsCount int, deletedManifestsCount int, err error) {

// Load ABAC batch size from environment variable
abacBatchSize := 10 // default
if envVal, exists := os.LookupEnv("ABAC_BATCH_SIZE"); exists {
if parsed, err := strconv.Atoi(envVal); err == nil && parsed > 0 {
abacBatchSize = parsed
}
}

// Collect all repository names into a slice for batching
repos := make([]string, 0, len(tagFilters))
for repoName := range tagFilters {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Go map iteration is non-deterministic, causing unstable batching and test results. Consider sorting repos before batching

repos = append(repos, repoName)
}

// Track which repositories have been successfully processed for error reporting.
var completedRepos []string

// Process repositories in batches of abacBatchSize.
// For ABAC-enabled registries, we set the current repositories for the batch so that
// token refresh happens dynamically when needed (on API calls that detect token expiration).
// For non-ABAC registries, the batching loop is harmless (no special token handling needed).
for i := 0; i < len(repos); i += abacBatchSize {
end := i + abacBatchSize
if end > len(repos) {
end = len(repos)
}
batch := repos[i:end]

// For ABAC registries, refresh the token with scopes for this batch of repositories.
// ABAC registries don't support wildcard repository scopes, so we must explicitly
// request access for each repository before operating on it.
if acrClient.IsAbac() {
if err := acrClient.RefreshTokenForAbac(ctx, batch); err != nil {
return deletedTagsCount, deletedManifestsCount, fmt.Errorf("failed to refresh ABAC token for batch: %w", err)
}
if verbose {
fmt.Printf("ABAC: Setting token scope for %d repositories: %v\n", len(batch), batch)
} else {
fmt.Printf("ABAC: Setting token scope for %d repositories\n", len(batch))
}
}

singleDeletedManifestsCount := 0
// If the untagged flag is set or untagged-only mode is enabled, delete manifests
if removeUntaggedManifests {
singleDeletedManifestsCount, err = purgeDanglingManifests(ctx, acrClient, repoParallelism, loginURL, repoName, agoDuration, keep, manifestToTagsCountMap, dryRun, includeLocked)
if err != nil {
return deletedTagsCount, deletedManifestsCount, fmt.Errorf("failed to purge manifests: %w", err)
// Process all repositories in this batch
for _, repoName := range batch {
tagRegex := tagFilters[repoName]
var singleDeletedTagsCount int
var manifestToTagsCountMap map[string]int

// Handle tag deletion based on mode
if untaggedOnly {
// Initialize empty map for untagged-only mode (no tag deletion)
manifestToTagsCountMap = make(map[string]int)
} else {
// Standard mode: delete matching tags first
singleDeletedTagsCount, manifestToTagsCountMap, err = purgeTags(ctx, acrClient, repoParallelism, loginURL, repoName, agoDuration, tagRegex, keep, filterTimeout, dryRun, includeLocked)
if err != nil {
if isUnauthorizedError(err) {
remainingRepos := repos[i+indexOf(batch, repoName):]
return deletedTagsCount, deletedManifestsCount,
formatPermissionError(repoName, "purge tags", completedRepos, remainingRepos)
}
return deletedTagsCount, deletedManifestsCount, fmt.Errorf("failed to purge tags: %w", err)
}
}

singleDeletedManifestsCount := 0
// If the untagged flag is set or untagged-only mode is enabled, delete manifests
if removeUntaggedManifests {
singleDeletedManifestsCount, err = purgeDanglingManifests(ctx, acrClient, repoParallelism, loginURL, repoName, agoDuration, keep, manifestToTagsCountMap, dryRun, includeLocked)
if err != nil {
if isUnauthorizedError(err) {
remainingRepos := repos[i+indexOf(batch, repoName):]
return deletedTagsCount, deletedManifestsCount,
formatPermissionError(repoName, "purge manifests", completedRepos, remainingRepos)
}
return deletedTagsCount, deletedManifestsCount, fmt.Errorf("failed to purge manifests: %w", err)
}
}
// After every repository is purged the counters are updated.
deletedTagsCount += singleDeletedTagsCount
deletedManifestsCount += singleDeletedManifestsCount
completedRepos = append(completedRepos, repoName)
}
// After every repository is purged the counters are updated.
deletedTagsCount += singleDeletedTagsCount
deletedManifestsCount += singleDeletedManifestsCount
}

return deletedTagsCount, deletedManifestsCount, nil
Expand Down Expand Up @@ -563,3 +625,51 @@ func purgeDanglingManifests(ctx context.Context, acrClient api.AcrCLIClientInter
}
return deletedManifestsCount, nil
}

// isUnauthorizedError checks if an error is an HTTP 401 Unauthorized response.
// This is used to detect permission failures on ABAC-enabled registries where
// the user may have access to some repositories but not others.
func isUnauthorizedError(err error) bool {
if err == nil {
return false
}
var detailedErr autorest.DetailedError
if errors.As(err, &detailedErr) {
if statusCode, ok := detailedErr.StatusCode.(int); ok {
return statusCode == http.StatusUnauthorized
}
}
return strings.Contains(err.Error(), "StatusCode=401")
}

// formatPermissionError builds a clear error message when a purge operation fails
// due to insufficient permissions on a repository. It reports which repository
// failed, which repositories were already processed, and which remain untouched.
func formatPermissionError(failedRepo string, operation string, completedRepos []string, remainingRepos []string) error {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("insufficient permissions to %s for repository %q", operation, failedRepo))

if len(completedRepos) > 0 {
sb.WriteString(fmt.Sprintf("\n Completed repositories (%d): %s", len(completedRepos), strings.Join(completedRepos, ", ")))
} else {
sb.WriteString("\n Completed repositories: none")
}

// remainingRepos includes the failed repo; show the ones after it as not yet processed
if len(remainingRepos) > 1 {
sb.WriteString(fmt.Sprintf("\n Remaining repositories not yet processed (%d): %s", len(remainingRepos)-1, strings.Join(remainingRepos[1:], ", ")))
}

sb.WriteString("\n Hint: use a more specific --filter to target only repositories you have permissions for")
return errors.New(sb.String())
}

// indexOf returns the index of s in slice, or 0 if not found.
func indexOf(slice []string, s string) int {
for i, v := range slice {
if v == s {
return i
}
}
return 0
}
6 changes: 5 additions & 1 deletion cmd/acr/purge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -552,9 +552,13 @@ func TestDryRun(t *testing.T) {
t.Run("RepositoryNotFoundTest", func(t *testing.T) {
assert := assert.New(t)
mockClient := &mocks.AcrCLIClientInterface{}
// Mock IsAbac to return false (non-ABAC registry) to use standard wildcard token flow
mockClient.On("IsAbac").Return(false)
// Need a .Maybe() since it's only called for ABAC registries (this test mocks IsAbac to return false)
mockClient.On("IsTokenExpired").Return(false).Maybe()
mockClient.On("GetAcrManifests", mock.Anything, testRepo, "", "").Return(notFoundManifestResponse, errors.New("testRepo not found")).Once()
mockClient.On("GetAcrTags", mock.Anything, testRepo, "timedesc", "").Return(notFoundTagResponse, errors.New("testRepo not found")).Once()
deletedTags, deletedManifests, err := purge(testCtx, mockClient, testLoginURL, 60, -24*time.Hour, 0, 1, true, false, map[string]string{testRepo: "[\\s\\S]*"}, true, false)
deletedTags, deletedManifests, err := purge(testCtx, mockClient, testLoginURL, 60, -24*time.Hour, 0, 1, true, false, map[string]string{testRepo: "[\\s\\S]*"}, true, false, false)
assert.Equal(0, deletedTags, "Number of deleted elements should be 0")
assert.Equal(0, deletedManifests, "Number of deleted elements should be 0")
assert.Equal(nil, err, "Error should be nil")
Expand Down
Loading