feat: add --untagged-only for independent manifest cleanup#489
feat: add --untagged-only for independent manifest cleanup#489balcsida wants to merge 14 commits intoAzure:mainfrom
--untagged-only for independent manifest cleanup#489Conversation
1dda8ef to
fe3c53c
Compare
|
@estebanreyl, as the |
e718249 to
a160bef
Compare
|
I like the change, but I am little hesitant when it comes to this type of usability change scenarios, so I messaged a couple of PMs to take a look and will see what they say and will update. I am sorry its taken so long |
|
Again, no worries at all, @estebanreyl! 🙇 |
|
Hey @balcsida , Thanks for making several PRs to |
|
@FeynmanZhou Thanks for the suggestion! I'm familiar with retention policies, but they don't address the specific issues this PR solves: Organizational constraintsSome organizations (like ours) face significant bureaucratic hurdles when implementing policies (especially preview ones). A scheduled cleanup pipeline using existing CLI tools is often much more practical to deploy and manage. User experience issuesThe linked issues (#64, #83, #95, #480, #486) demonstrate a clear pattern: users consistently expect the Complementary solutionRather than replacing retention policies, this PR provides an alternative approach for teams that need more granular control or face organizational constraints. The
This change maintains full backward compatibility while preventing the unintended manifest deletions that several users have reported. |
--untagged-only for independent manifest cleanup
|
Since this PR has been already merged and it has a breaking change, I think that this one should be merged ASAP too. |
|
Hey @escherstair , Apologies for breaking your workflows with my previous PR, that was not my intention. I'm going to rebase my branch and fix the conflicts to make this PR mergable as soon as I can |
a160bef to
d0a7c7a
Compare
- Add unit tests for untagged-only flag validation - Add integration test script for real Azure registry testing - Update main test script with untagged-only test cases - Fix index out of range bug in GetUntaggedManifests - Test scenarios include dry-run, locked manifests, and filters
- Remove GetManifest expectations for untagged manifests (not called) - Fix UpdateAcrManifestAttributes mock to return proper response - Update GetRepositories test to match actual behavior - Update mutual exclusivity test to accept Cobra's error message - All unit tests now passing (100% success rate)
d0a7c7a to
4298857
Compare
|
@escherstair, the ASAP happened to be today, thanks for your patience with me |
|
@balcsida don't worry. |
cmd/acr/purge.go
Outdated
|
|
||
| - Delete all tags that contain the word test in the tag name and are older than 5 days in the example.azurecr.io registry inside the hello-world | ||
| repository, after that, remove the dangling manifests in the same repository | ||
| - Delete tags containing "test" that are older than 5 days, then clean up any dangling manifests left behind |
There was a problem hiding this comment.
Ago also applies to the dangling manifests , something clearer would be something like:
"Delete tags containing ‘test’ that are older than 5 days, then delete any dangling (untagged) manifests older than 5 days"
There was a problem hiding this comment.
Updated example text to clarify
estebanreyl
left a comment
There was a problem hiding this comment.
Overall LGTM, we are in line with including the changes. I've added some comments on some small items that would be nice to address before merge. Thanks again for the contribution!
cmd/acr/purge.go
Outdated
| Example: purgeExampleMessage, | ||
| RunE: func(_ *cobra.Command, _ []string) error { | ||
| // Validate flag combinations before authentication | ||
| if !purgeParams.untaggedOnly && !purgeParams.untagged { |
There was a problem hiding this comment.
Doesn't --untagged still require filter?
There was a problem hiding this comment.
True! Added validation for --untagged requiring filter and ago
cmd/acr/purge.go
Outdated
| } | ||
| tagFilters = make(map[string]string) | ||
| for _, repoName := range allRepoNames { | ||
| tagFilters[repoName] = ".*" // dummy filter that won't be used |
There was a problem hiding this comment.
If the tag filter won't be used it seems most logical to use a filter string that cannot be matched or adjust the structure. Maybe use ^$ or the empty string?
There was a problem hiding this comment.
Changed to empty string
cmd/acr/purge.go
Outdated
| } | ||
|
|
||
| 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) |
There was a problem hiding this comment.
Maybe combine the purgeParams.untagged || purgeParams.untaggedOnly in a variable before and call it supportUntaggedCleanup? Just to make it clear, I see they are marked as mutually exclusive so just makes it clear.
cmd/acr/purge.go
Outdated
|
|
||
| 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.untagged, "untagged", false, "In addition to deleting tags (based on --filter and --ago), also delete untagged manifests that were left behind after tag deletion. This is typically used as a cleanup step after deleting tags. Note: This requires --filter and --ago to be specified") | ||
| cmd.Flags().BoolVar(&purgeParams.untaggedOnly, "untagged-only", false, "Clean up dangling manifests: Delete ONLY untagged manifests (manifests without any tags), without deleting any tags first. This is the primary way to clean up dangling manifests in your registry. Optional: Use --ago to delete only old untagged manifests, --keep to preserve recent ones, and --filter to target specific repositories") |
There was a problem hiding this comment.
Can we note this ignores the tag portion of filter?
There was a problem hiding this comment.
Good catch, added note to flag description
cmd/acr/purge.go
Outdated
| if err != nil { | ||
| return 0, 0, err | ||
| // For untagged-only mode without --ago, use 0 duration to delete all untagged manifests | ||
| var agoDuration time.Duration |
There was a problem hiding this comment.
This should probably be done as part of the parameter validation above
cmd/acr/purge.go
Outdated
| var manifestToTagsCountMap map[string]int | ||
|
|
||
| // Skip tag deletion if untagged-only mode is enabled | ||
| if !untaggedOnly { |
There was a problem hiding this comment.
Kinda a nit but we should have the default behavior first and avoid the not check so swapping the order like:
if untaggedOnly{
} else {
}
cmd/acr/purge.go
Outdated
| // If the untagged flag is set or untagged-only mode is enabled, delete manifests | ||
| if removeUtaggedManifests { | ||
| singleDeletedManifestsCount, err = purgeDanglingManifests(ctx, acrClient, repoParallelism, loginURL, repoName, agoDuration, manifestToTagsCountMap, dryRun, includeLocked) | ||
| singleDeletedManifestsCount, err = purgeDanglingManifests(ctx, acrClient, repoParallelism, loginURL, repoName, agoDuration, tagsToKeep, manifestToTagsCountMap, dryRun, includeLocked) |
There was a problem hiding this comment.
Should this be changed from tagsToKeep to imagesToKeep?
cmd/acr/purge.go
Outdated
| if manifestsToDelete[i].LastUpdateTime == nil || manifestsToDelete[j].LastUpdateTime == nil { | ||
| return false | ||
| } | ||
| tiI, errI := time.Parse(time.RFC3339Nano, *manifestsToDelete[i].LastUpdateTime) |
There was a problem hiding this comment.
I don't expect this to happen, but this leads to slightly mysterious/inconsistent behavior in the event that we do fail to parse the last updateTime as cases where less(i, j) == false and less(j, i) == false can happen without the items being equal (because you haven’t actually compared anything). I would suggest establishing a consistent ordering (for example the nil one is always considered older) and maybe moving the keep functionality to its own function so it can be tested independently. I asked copilot to do it and it gave me this:
sort.Slice(manifestsToDelete, func(i, j int) bool {
mi := manifestsToDelete[i]
mj := manifestsToDelete[j]
// Extract strings; nil => invalid
var si, sj string
if mi.LastUpdateTime != nil {
si = *mi.LastUpdateTime
}
if mj.LastUpdateTime != nil {
sj = *mj.LastUpdateTime
}
ti, errI := time.Parse(time.RFC3339Nano, si)
if errI != nil && si != "" {
// fallback to RFC3339 if needed
if t, err := time.Parse(time.RFC3339, si); err == nil {
ti, errI = t, nil
}
}
tj, errJ := time.Parse(time.RFC3339Nano, sj)
if errJ != nil && sj != "" {
if t, err := time.Parse(time.RFC3339, sj); err == nil {
tj, errJ = t, nil
}
}
// Define validity flags
vi := (errI == nil)
vj := (errJ == nil)
// Ordering rules (total order):
// 1) Valid timestamps come before invalid (invalid considered oldest).
if vi != vj {
return vi // true if i valid and j invalid
}
// 2) Both valid: newest first.
if vi && vj {
if ti.Equal(tj) {
// 3) Tie-breaker for determinism (adjust to your field)
return mi.Digest < mj.Digest
}
return ti.After(tj) // newest first
}
// 4) Both invalid: tie-breaker for determinism
return mi.Digest < mj.Digest
})| # Test regular purge with --untagged performance | ||
| echo "Testing regular purge with --untagged performance..." | ||
| start_time=$(date +%s%N) | ||
| "$ACR_CLI" purge --registry "$REGISTRY" --filter "$repo:.*" --ago 0d --untagged --dry-run >/dev/null 2>&1 |
There was a problem hiding this comment.
to have a real comparison shouldn't you avoid cleaning up any tags, so use 0^$ here?
a846786 to
7e2e6c5
Compare
|
Thank you @estebanreyl for the quick review 🙇 |
cmd/acr/purge.go
Outdated
| // untagged-only mode: filter and ago are optional (skip validation) | ||
| // untagged mode: requires filter and ago (it's a cleanup step after tag deletion) | ||
| // standard mode: requires filter and ago for tag deletion | ||
| if purgeParams.untagged { |
There was a problem hiding this comment.
I don't think this first if statement is necessary. It seems like the second else if actually covers all the necessary scenarios. I don't think there is a need for any special message that needs to be output for --untagged vs just regular tag cleanup.
Purpose of the PR
This PR introduces a new
--untagged-onlyflag to the purge command, enabling users to clean up untagged manifests independently without affecting tagged manifests. This addresses the confusion and limitations reported in various issues and provides a comprehensive solution for dangling manifest cleanup.Key Features Added
--untagged-onlyflag: Exclusively targets untagged manifests without deleting any tags first--agoand--keepsupport: When used with--untagged-only, these flags become optional and provide additional filtering capabilities:--ago: Filter untagged manifests by age (delete only manifests older than specified duration)--keep: Preserve the most recent N untagged manifests--untaggedand--untagged-onlyImplementation Details
--untaggedand--untagged-onlyare mutually exclusive to prevent confusionpurgeDanglingManifestsfunction--keepis specifiedEnhanced User Experience
--untagged-onlyworkflow)Usage Examples
Key Differences Between Flags
--untagged: Used as a cleanup step after tag deletion. Requires--filterand--ago. Deletes tags first, then cleans up untagged manifests left behind.--untagged-only: Primary tool for dangling manifest cleanup. Works independently without deleting tags. Optional--filter,--ago, and--keepfor targeted cleanup.Breaking Changes
None. The existing
--untaggedflag behavior remains unchanged for backward compatibility.Testing
Fixes #64
Fixes #83
Fixes #95
Fixes #480
Fixes #486