Skip to content

feat: add --untagged-only flag for independent untagged manifest cleanup #489

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,25 @@ acr purge \
--untagged
```

#### Untagged-only flag

To delete ONLY untagged manifests without deleting any tags, the `--untagged-only` flag should be set. This flag makes the `--ago` and `--keep` flags not applicable, and `--filter` becomes optional.

```sh
# Delete untagged manifests in all repositories
acr purge \
--registry <Registry Name> \
--untagged-only

# Delete untagged manifests in specific repositories matching a filter
acr purge \
--registry <Registry Name> \
--filter <Repository Filter/Name>:<Regex Filter> \
--untagged-only
```

Note: The `--untagged` and `--untagged-only` flags are mutually exclusive.

#### Keep flag

To keep the latest x number of to-be-deleted tags, the `--keep` flag should be set.
Expand Down
76 changes: 66 additions & 10 deletions cmd/acr/purge.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ const (
repository, after that, remove the dangling manifests in the same repository
acr purge -r example --filter "hello-world:\w*test\w*" --ago 5d --untagged

- Delete only untagged manifests in all repositories in the example.azurecr.io registry
acr purge -r example --untagged-only

- Delete only untagged manifests in the hello-world repository in the example.azurecr.io registry
acr purge -r example --filter "hello-world:.*" --untagged-only

- Delete all tags older than 1 day in the example.azurecr.io registry inside the hello-world repository using the credentials found in
the C://Users/docker/config.json path
acr purge -r example --filter "hello-world:.*" --ago 1d --config C://Users/docker/config.json
Expand Down Expand Up @@ -75,6 +81,7 @@ type purgeParameters struct {
filters []string
filterTimeout int64
untagged bool
untaggedOnly bool
dryRun bool
includeLocked bool
concurrency int
Expand Down Expand Up @@ -102,11 +109,47 @@ func newPurgeCmd(rootParams *rootParameters) *cobra.Command {
if err != nil {
return err
}

// Validate flag combinations
if purgeParams.untaggedOnly {
// When untagged-only is set, ago and keep flags are not applicable
if purgeParams.ago != "" {
return fmt.Errorf("--ago flag is not applicable when --untagged-only is set")
}
if purgeParams.keep != 0 {
return fmt.Errorf("--keep flag is not applicable when --untagged-only is set")
}
} else if !purgeParams.untagged {
// When neither untagged nor untagged-only is set, require filter and ago
if len(purgeParams.filters) == 0 {
return fmt.Errorf("--filter is required when not using --untagged-only")
}
if purgeParams.ago == "" {
return fmt.Errorf("--ago is required when not using --untagged-only")
}
}

// A map is used to collect the regex tags for every repository.
tagFilters, err := common.CollectTagFilters(ctx, purgeParams.filters, acrClient.AutorestClient, purgeParams.filterTimeout, purgeParams.repoPageSize)
if err != nil {
return err
var tagFilters map[string]string
if purgeParams.untaggedOnly && len(purgeParams.filters) == 0 {
// If untagged-only without filters, get all repositories
allRepoNames, err := common.GetAllRepositoryNames(ctx, acrClient.AutorestClient, purgeParams.repoPageSize)
if err != nil {
return err
}
tagFilters = make(map[string]string)
for _, repoName := range allRepoNames {
tagFilters[repoName] = ".*" // dummy filter that won't be used
}
} else if len(purgeParams.filters) > 0 {
tagFilters, err = common.CollectTagFilters(ctx, purgeParams.filters, acrClient.AutorestClient, purgeParams.filterTimeout, purgeParams.repoPageSize)
if err != nil {
return err
}
} else {
tagFilters = make(map[string]string)
}

// A clarification message for --dry-run.
if purgeParams.dryRun {
fmt.Println("DRY RUN: The following output shows what WOULD be deleted if the purge command was executed. Nothing is deleted.")
Expand All @@ -123,7 +166,7 @@ func newPurgeCmd(rootParams *rootParameters) *cobra.Command {
fmt.Printf("Specified concurrency value too large. Set to maximum value: %d \n", maxPoolSize)
}

deletedTagsCount, deletedManifestsCount, err := purge(ctx, acrClient, loginURL, repoParallelism, purgeParams.ago, purgeParams.keep, purgeParams.filterTimeout, purgeParams.untagged, tagFilters, purgeParams.dryRun, purgeParams.includeLocked)
deletedTagsCount, deletedManifestsCount, err := purge(ctx, acrClient, loginURL, repoParallelism, purgeParams.ago, purgeParams.keep, purgeParams.filterTimeout, purgeParams.untagged || purgeParams.untaggedOnly, purgeParams.untaggedOnly, tagFilters, purgeParams.dryRun, purgeParams.includeLocked)

if err != nil {
fmt.Printf("Failed to complete purge: %v \n", err)
Expand All @@ -143,6 +186,7 @@ func newPurgeCmd(rootParams *rootParameters) *cobra.Command {
}

cmd.Flags().BoolVar(&purgeParams.untagged, "untagged", false, "If the untagged flag is set all the manifests that do not have any tags associated to them will be also purged, except if they belong to a manifest list that contains at least one tag")
cmd.Flags().BoolVar(&purgeParams.untaggedOnly, "untagged-only", false, "If the untagged-only flag is set, only untagged manifests will be purged without deleting any tags. When this flag is used, --ago and --filter become optional")
cmd.Flags().BoolVar(&purgeParams.dryRun, "dry-run", false, "If the dry-run flag is set no manifest or tag will be deleted, the output would be the same as if they were deleted")
cmd.Flags().BoolVar(&purgeParams.includeLocked, "include-locked", false, "If the include-locked flag is set, locked manifests and tags (where deleteEnabled or writeEnabled is false) will be unlocked before deletion")
cmd.Flags().StringVar(&purgeParams.ago, "ago", "", "The tags that were last updated before this duration will be deleted, the format is [number]d[string] where the first number represents an amount of days and the string is in a Go duration format (e.g. 2d3h6m selects images older than 2 days, 3 hours and 6 minutes)")
Expand All @@ -153,8 +197,9 @@ func newPurgeCmd(rootParams *rootParameters) *cobra.Command {
cmd.Flags().IntVar(&purgeParams.concurrency, "concurrency", defaultPoolSize, concurrencyDescription)
cmd.Flags().Int32Var(&purgeParams.repoPageSize, "repository-page-size", defaultRepoPageSize, repoPageSizeDescription)
cmd.Flags().BoolP("help", "h", false, "Print usage")
cmd.MarkFlagRequired("filter")
cmd.MarkFlagRequired("ago")
// Make filter and ago conditionally required based on untagged-only flag
cmd.MarkFlagsOneRequired("filter", "untagged-only")
cmd.MarkFlagsMutuallyExclusive("untagged", "untagged-only")
return cmd
}

Expand All @@ -166,18 +211,29 @@ func purge(ctx context.Context,
tagsToKeep int,
filterTimeout int64,
removeUtaggedManifests bool,
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 {
singleDeletedTagsCount, manifestToTagsCountMap, err := purgeTags(ctx, acrClient, repoParallelism, loginURL, repoName, tagDeletionSince, tagRegex, tagsToKeep, filterTimeout, dryRun, includeLocked)
if err != nil {
return deletedTagsCount, deletedManifestsCount, fmt.Errorf("failed to purge tags: %w", err)
var singleDeletedTagsCount int
var manifestToTagsCountMap map[string]int

// Skip tag deletion if untagged-only mode is enabled
if !untaggedOnly {
singleDeletedTagsCount, manifestToTagsCountMap, err = purgeTags(ctx, acrClient, repoParallelism, loginURL, repoName, tagDeletionSince, tagRegex, tagsToKeep, filterTimeout, dryRun, includeLocked)
if err != nil {
return deletedTagsCount, deletedManifestsCount, fmt.Errorf("failed to purge tags: %w", err)
}
} else {
// Initialize empty map for untagged-only mode
manifestToTagsCountMap = make(map[string]int)
}

singleDeletedManifestsCount := 0
// If the untagged flag is set then also manifests are deleted.
// If the untagged flag is set or untagged-only mode is enabled, delete manifests
if removeUtaggedManifests {
singleDeletedManifestsCount, err = purgeDanglingManifests(ctx, acrClient, repoParallelism, loginURL, repoName, manifestToTagsCountMap, dryRun, includeLocked)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/acr/purge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ func TestDryRun(t *testing.T) {
mockClient := &mocks.AcrCLIClientInterface{}
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, "1d", 0, 1, true, map[string]string{testRepo: "[\\s\\S]*"}, true, false)
deletedTags, deletedManifests, err := purge(testCtx, mockClient, testLoginURL, 60, "1d", 0, 1, true, false, map[string]string{testRepo: "[\\s\\S]*"}, true, 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