Skip to content
Draft
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,9 @@
coverage.txt
*.test
*.out

# Build artifacts
/acr-cli
/acr/acr
/cmd/acr/acr
*.html
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,10 @@ acr purge \
--include-locked
```

#### ABAC batch size (environment variable)
For registries with ABAC enabled, repositories are processed in batches. The batch size controls how many repositories share a single token scope. Token refresh happens dynamically when API calls detect token expiration, using the current batch's repository scope. 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
95 changes: 69 additions & 26 deletions cmd/acr/purge.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import (
"context"
"fmt"
"net/http"
"os"
"runtime"
"sort"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -89,6 +91,7 @@ type purgeParameters struct {
includeLocked bool
concurrency int
repoPageSize int32
verbose bool
}

// newPurgeCmd defines the purge command.
Expand Down Expand Up @@ -178,7 +181,7 @@ 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 {
fmt.Printf("Failed to complete purge: %v \n", err)
Expand Down Expand Up @@ -208,6 +211,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 +230,75 @@ 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 {
repos = append(repos, repoName)
}

// 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, set the current repositories for this batch.
// Token refresh will happen dynamically when API calls detect token expiration.
if acrClient.IsAbac() {
acrClient.SetCurrentRepositories(batch)
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 {
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 {
return deletedTagsCount, deletedManifestsCount, fmt.Errorf("failed to purge manifests: %w", err)
}
}
// After every repository is purged the counters are updated.
deletedTagsCount += singleDeletedTagsCount
deletedManifestsCount += singleDeletedManifestsCount
}
// After every repository is purged the counters are updated.
deletedTagsCount += singleDeletedTagsCount
deletedManifestsCount += singleDeletedManifestsCount
}

return deletedTagsCount, deletedManifestsCount, nil
Expand Down
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