Skip to content
Draft
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
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,4 @@
/bin/
coverage.txt
*.test
*.out
*.html
*.out
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