Skip to content

Commit cfadfa9

Browse files
balcsidaclaude
andauthored
feat(purge): add --include-locked flag to purge command (#479)
* feat: add --include-locked flag to annotate command - Add --include-locked flag to annotate command to handle locked manifests/tags - Skip locked manifests and tags when --include-locked is not set - Display "x manifests/tags skipped as they are locked" messages - Update function signatures to include includeLocked parameter and return skip counts - Add comprehensive test coverage for new functionality - Update documentation in README.md and CLAUDE.md * feat: rename --force flag to --include-locked in purge command - Rename --force flag to --include-locked across the entire codebase - Update purge command help text and examples - Update function signatures and parameter names throughout: - purgeParams.force -> purgeParams.includeLocked - force parameter -> includeLocked parameter - Update all tests to use new flag name - Maintain same functionality: unlock locked manifests/tags before deletion * fix: improve dry run output messages in purge command Change "Deleting" to "Would delete" in dry run mode to make output less scary and more accurate. * docs: add warning to readme Signed-off-by: Dávid Balatoni <[email protected]> * refactor: change GetUntaggedManifests to return ManifestAttributesBase objects Changes GetUntaggedManifests function signature from returning []string to []acr.ManifestAttributesBase to provide full manifest attributes to callers. This enables optimization by avoiding extra API calls for manifest attributes in downstream functions. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * feat: optimize PurgeManifests to use existing manifest attributes Eliminates extra GetAcrManifestAttributes API call per manifest by using the manifest attributes already provided by GetUntaggedManifests. This significantly improves performance when purging many locked manifests with the --include-locked flag. Also improves error handling by continuing with deletion attempts even when unlock operations fail. * fix: update callers to handle new GetUntaggedManifests return type Updates purge.go and annotate.go to handle the new ManifestAttributesBase return type from GetUntaggedManifests. The annotate command converts the manifest objects to digest strings for compatibility with the existing Annotate method. * test: update tests to reflect PurgeManifests optimization Removes expectation for GetAcrManifestAttributes call in purge_test.go since the optimization eliminates this API call. The test now correctly validates that the function works with the manifest attributes already provided by GetUntaggedManifests. * test: add tests for --include-locked flag in annotate command Adds comprehensive tests for the --include-locked flag functionality in the annotate command, including: - Test that locked tags are included when flag is set - Test that locked tags are skipped when flag is not set - Test that locked untagged manifests are included when flag is set - Test dry run behavior with locked manifests Note: Unlike the purge command, annotate does not currently unlock manifests before annotating them. The tests document the current behavior of filtering locked manifests. * fix: uneccessary singleSkippedManifestsCount * fix: handle HTTP 405 responses gracefully in purger deletion operations * test: add functional test script * refactor: move testing scripts to scripts/experimental * fix: testing README * test: improve test scripts with hyperfine * test: faster image generation * test: image generation fix * test: use more images by default * test: no warmups and only run everything once * test: compare locked and unlocked --------- Signed-off-by: Dávid Balatoni <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 96ed7eb commit cfadfa9

14 files changed

+2961
-141
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,19 @@ acr purge \
208208
--repository-page-size 10
209209
```
210210

211+
#### Include-locked flag
212+
To delete locked manifests and tags (where deleteEnabled or writeEnabled is false), the `--include-locked` flag should be set. This will unlock them before deletion.
213+
214+
**Warning:** The `--include-locked` flag will unlock and delete images that have been locked for protection. Use this flag with caution as it bypasses the image lock mechanism. For more information about image locking, see [Lock a container image in an Azure container registry](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-image-lock).
215+
216+
```sh
217+
acr purge \
218+
--registry <Registry Name> \
219+
--filter <Repository Filter/Name>:<Regex Filter> \
220+
--ago 30d \
221+
--include-locked
222+
```
223+
211224
### Integration with ACR Tasks
212225

213226
To run a locally built version of the ACR-CLI using ACR Tasks follow these steps:

cmd/acr/annotate.go

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ type annotateParameters struct {
5252
untagged bool
5353
dryRun bool
5454
concurrency int
55+
includeLocked bool
5556
}
5657

5758
// newAnnotateCmd defines the annotate command
@@ -93,6 +94,8 @@ func newAnnotateCmd(rootParams *rootParameters) *cobra.Command {
9394
// In order to print a summary of the annotated tags/manifests, the counters get updated every time a repo is annotated.
9495
annotatedTagsCount := 0
9596
annotatedManifestsCount := 0
97+
skippedTagsCount := 0
98+
skippedManifestsCount := 0
9699

97100
poolSize := annotateParams.concurrency
98101
if poolSize <= 0 {
@@ -103,15 +106,15 @@ func newAnnotateCmd(rootParams *rootParameters) *cobra.Command {
103106
fmt.Printf("Specified concurrency value too large. Set to maximum value: %d \n", maxPoolSize)
104107
}
105108
for repoName, tagRegex := range tagFilters {
106-
singleAnnotatedTagsCount, err := annotateTags(ctx, acrClient, orasClient, poolSize, loginURL, repoName, annotateParams.artifactType, annotateParams.annotations, tagRegex, annotateParams.filterTimeout, annotateParams.dryRun)
109+
singleAnnotatedTagsCount, singleSkippedTagsCount, err := annotateTags(ctx, acrClient, orasClient, poolSize, loginURL, repoName, annotateParams.artifactType, annotateParams.annotations, tagRegex, annotateParams.filterTimeout, annotateParams.dryRun, annotateParams.includeLocked)
107110
if err != nil {
108111
return fmt.Errorf("failed to annotate tags: %w", err)
109112
}
110113

111114
singleAnnotatedManifestsCount := 0
112115
// If the untagged flag is set, then manifests with no tags are also annotated..
113116
if annotateParams.untagged {
114-
singleAnnotatedManifestsCount, err = annotateUntaggedManifests(ctx, acrClient, orasClient, poolSize, loginURL, repoName, annotateParams.artifactType, annotateParams.annotations, annotateParams.dryRun)
117+
singleAnnotatedManifestsCount, err = annotateUntaggedManifests(ctx, acrClient, orasClient, poolSize, loginURL, repoName, annotateParams.artifactType, annotateParams.annotations, annotateParams.dryRun, annotateParams.includeLocked)
115118
if err != nil {
116119
return fmt.Errorf("failed to annotate manifests: %w", err)
117120
}
@@ -120,6 +123,7 @@ func newAnnotateCmd(rootParams *rootParameters) *cobra.Command {
120123
// After every repository is annotated, the counters are updated
121124
annotatedTagsCount += singleAnnotatedTagsCount
122125
annotatedManifestsCount += singleAnnotatedManifestsCount
126+
skippedTagsCount += singleSkippedTagsCount
123127
}
124128

125129
// After all repos have been annotated, the summary is printed
@@ -130,6 +134,12 @@ func newAnnotateCmd(rootParams *rootParameters) *cobra.Command {
130134
fmt.Printf("\nNumber of annotated tags: %d", annotatedTagsCount)
131135
fmt.Printf("\nNumber of annotated manifests: %d\n", annotatedManifestsCount)
132136
}
137+
if skippedTagsCount > 0 {
138+
fmt.Printf("%d tags skipped as they are locked\n", skippedTagsCount)
139+
}
140+
if skippedManifestsCount > 0 {
141+
fmt.Printf("%d manifests skipped as they are locked\n", skippedManifestsCount)
142+
}
133143
return nil
134144
},
135145
}
@@ -141,6 +151,7 @@ func newAnnotateCmd(rootParams *rootParameters) *cobra.Command {
141151
cmd.Flags().StringSliceVarP(&annotateParams.annotations, "annotations", "a", []string{}, "The configurable annotation key value that can be specified one or more times")
142152
cmd.Flags().BoolVar(&annotateParams.untagged, "untagged", false, "If the untagged flag is set, all the manifests that do not have any tags associated to them will also be annotated, except if they belong to a manifest list that contains at least one tag")
143153
cmd.Flags().BoolVar(&annotateParams.dryRun, "dry-run", false, "If the dry-run flag is set, no manifest or tag will be annotated. The output would be the same as if they were annotated")
154+
cmd.Flags().BoolVar(&annotateParams.includeLocked, "include-locked", false, "If the include-locked flag is set, locked manifests and tags (where writeEnabled is false) will be annotated")
144155
cmd.Flags().IntVar(&annotateParams.concurrency, "concurrency", defaultPoolSize, annotatedConcurrencyDescription)
145156
cmd.Flags().BoolP("help", "h", false, "Print usage")
146157
cmd.MarkFlagRequired("filter")
@@ -160,7 +171,8 @@ func annotateTags(ctx context.Context,
160171
annotations []string,
161172
tagFilter string,
162173
regexpMatchTimeoutSeconds int64,
163-
dryRun bool) (int, error) {
174+
dryRun bool,
175+
includeLocked bool) (int, int, error) {
164176

165177
if !dryRun {
166178
fmt.Printf("\nAnnotating tags for repository: %s\n", repoName)
@@ -170,35 +182,37 @@ func annotateTags(ctx context.Context,
170182

171183
tagRegex, err := common.BuildRegexFilter(tagFilter, regexpMatchTimeoutSeconds)
172184
if err != nil {
173-
return -1, err
185+
return -1, 0, err
174186
}
175187

176188
lastTag := ""
177189
annotatedTagsCount := 0
190+
totalSkippedCount := 0
178191

179192
var annotator *worker.Annotator
180193
if !dryRun {
181194
// In order to only have a limited amount of http requests, an annotator is used that will start goroutines to annotate tags.
182195
annotator, err = worker.NewAnnotator(poolSize, orasClient, loginURL, repoName, artifactType, annotations)
183196
if err != nil {
184-
return -1, err
197+
return -1, 0, err
185198
}
186199
}
187200

188201
for {
189202
// GetTagsToAnnotate will return an empty lastTag when there are no more tags.
190-
manifestsToAnnotate, newLastTag, err := getManifestsToAnnotate(ctx, acrClient, orasClient, loginURL, repoName, tagRegex, lastTag, artifactType, dryRun)
203+
manifestsToAnnotate, newLastTag, skippedCount, err := getManifestsToAnnotate(ctx, acrClient, orasClient, loginURL, repoName, tagRegex, lastTag, artifactType, dryRun, includeLocked)
191204
if err != nil {
192-
return -1, err
205+
return -1, 0, err
193206
}
194207
lastTag = newLastTag
195208
count := len(manifestsToAnnotate)
209+
totalSkippedCount += skippedCount
196210
if count > 0 {
197211
if !dryRun {
198212
annotated, annotateErr := annotator.Annotate(ctx, manifestsToAnnotate)
199213
if annotateErr != nil {
200214
annotatedTagsCount += annotated
201-
return annotatedTagsCount, annotateErr
215+
return annotatedTagsCount, totalSkippedCount, annotateErr
202216
}
203217
}
204218
annotatedTagsCount += count
@@ -207,7 +221,7 @@ func annotateTags(ctx context.Context,
207221
break
208222
}
209223
}
210-
return annotatedTagsCount, nil
224+
return annotatedTagsCount, totalSkippedCount, nil
211225
}
212226

213227
// getManifestsToAnnotate gets all manifests that should be annotated according to the filter flag.
@@ -220,38 +234,40 @@ func getManifestsToAnnotate(ctx context.Context,
220234
loginURL string,
221235
repoName string,
222236
filter *regexp2.Regexp,
223-
lastTag string, artifactType string, dryRun bool) ([]string, string, error) {
237+
lastTag string, artifactType string, dryRun bool, includeLocked bool) ([]string, string, int, error) {
224238

225239
resultTags, err := acrClient.GetAcrTags(ctx, repoName, "timedesc", lastTag)
226240
if err != nil {
227241
if resultTags != nil && resultTags.Response.Response != nil && resultTags.StatusCode == http.StatusNotFound {
228242
fmt.Printf("%s repository not found\n", repoName)
229-
return nil, "", nil
243+
return nil, "", 0, nil
230244
}
231-
return nil, "", err
245+
return nil, "", 0, err
232246
}
233247

234248
newLastTag := ""
235249
if resultTags != nil && resultTags.TagsAttributes != nil && len(*resultTags.TagsAttributes) > 0 {
236250
tags := *resultTags.TagsAttributes
237251
manifestsToAnnotate := []string{}
252+
skippedCount := 0
238253
for _, tag := range tags {
239254
matches, err := filter.MatchString(*tag.Name)
240255
if err != nil {
241256
// The only error that regexp2 will return is a timeout error
242-
return nil, "", err
257+
return nil, "", 0, err
243258
}
244259
if !matches {
245260
// If a tag does not match the regex then it's not added to the list
246261
continue
247262
}
248263

249-
// If a tag is changable, then it is returned as a tag to annotate
250-
if *tag.ChangeableAttributes.WriteEnabled {
264+
// If a tag is changeable, then it is returned as a tag to annotate
265+
// With --include-locked flag, locked tags are also eligible for annotation
266+
if includeLocked || *tag.ChangeableAttributes.WriteEnabled {
251267
ref := fmt.Sprintf("%s/%s:%s", loginURL, repoName, *tag.Name)
252268
skip, err := orasClient.DiscoverLifecycleAnnotation(ctx, ref, artifactType)
253269
if err != nil {
254-
return nil, "", err
270+
return nil, "", 0, err
255271
}
256272
if !skip {
257273
// Only print what would be annotated during a dry-run. Successfully annotated manifests
@@ -261,13 +277,16 @@ func getManifestsToAnnotate(ctx context.Context,
261277
}
262278
manifestsToAnnotate = append(manifestsToAnnotate, *tag.Digest)
263279
}
280+
} else {
281+
// Tag is locked and --include-locked is not set, skip it
282+
skippedCount++
264283
}
265284
}
266285

267286
newLastTag = common.GetLastTagFromResponse(resultTags)
268-
return manifestsToAnnotate, newLastTag, nil
287+
return manifestsToAnnotate, newLastTag, skippedCount, nil
269288
}
270-
return nil, "", nil
289+
return nil, "", 0, nil
271290
}
272291

273292
// annotateUntaggedManifests annotates all manifests that do not have any tags associated with them except the ones
@@ -278,7 +297,7 @@ func annotateUntaggedManifests(ctx context.Context,
278297
poolSize int, loginURL string,
279298
repoName string, artifactType string,
280299
annotations []string,
281-
dryRun bool) (int, error) {
300+
dryRun bool, includeLocked bool) (int, error) {
282301
if !dryRun {
283302
fmt.Printf("Annotating manifests for repository: %s\n", repoName)
284303
} else {
@@ -288,7 +307,7 @@ func annotateUntaggedManifests(ctx context.Context,
288307
// Contrary to getTagsToAnnotate, getManifests gets all the manifests at once.
289308
// This was done because if there is a manifest that has no tag but is referenced by a multiarch manifest that has tags then it
290309
// should not be annotated.
291-
manifestsToAnnotate, err := common.GetUntaggedManifests(ctx, poolSize, acrClient, repoName, true, nil, dryRun)
310+
manifestsToAnnotate, err := common.GetUntaggedManifests(ctx, poolSize, acrClient, repoName, true, nil, dryRun, includeLocked)
292311
if err != nil {
293312
return -1, err
294313
}
@@ -301,14 +320,27 @@ func annotateUntaggedManifests(ctx context.Context,
301320
if err != nil {
302321
return -1, err
303322
}
304-
manifestsCount, annotateErr := annotator.Annotate(ctx, manifestsToAnnotate)
323+
// Convert ManifestAttributesBase to digest strings for annotator
324+
manifestDigests := make([]string, 0, len(manifestsToAnnotate))
325+
for _, manifest := range manifestsToAnnotate {
326+
if manifest.Digest != nil {
327+
manifestDigests = append(manifestDigests, *manifest.Digest)
328+
}
329+
}
330+
manifestsCount, annotateErr := annotator.Annotate(ctx, manifestDigests)
305331
if annotateErr != nil {
306332
annotatedManifestsCount += manifestsCount
307333
return annotatedManifestsCount, annotateErr
308334
}
309335
annotatedManifestsCount += manifestsCount
310336
} else {
311337
annotatedManifestsCount = len(manifestsToAnnotate)
338+
// In dry run mode, print which manifests would be annotated
339+
for _, manifest := range manifestsToAnnotate {
340+
if manifest.Digest != nil {
341+
fmt.Printf("Would annotate: %s/%s@%s\n", loginURL, repoName, *manifest.Digest)
342+
}
343+
}
312344
}
313345

314346
return annotatedManifestsCount, nil

0 commit comments

Comments
 (0)