Skip to content

Commit ecb605a

Browse files
authored
fix(purge): enforce ago limits and improve docs (#564)
* Add validation for purge ago values that overflow * Move ago parsing to only happen once * update docs
1 parent 2230268 commit ecb605a

File tree

3 files changed

+127
-80
lines changed

3 files changed

+127
-80
lines changed

README.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,17 @@ The filter flag is used to specify the repository and a regex filter, if a tag i
121121

122122
Examples of filters
123123

124-
| Intention | Flag |
125-
|----------------------------------------------------------------------------------|---------------------------------------|
126-
| Untag all tags that begin with hello in app repository | --filter `"app:^hello.*"` |
127-
| Untag tags that end with world in app repository | --filter `"app:\w*world\b"` |
128-
| Untag tags that include hello-world in their name in app repository | --filter `"app:hello-world"` |
129-
| Untag all tags that are older than the duration in repositories ending in /cache | --filter `".*/cache:.*"` |
130-
| Untag all tags that are older than the duration in app repository | --filter `"app:.*"` |
131-
| Untag all tags that are older than the duration in all repositories | --filter `".*:.*"` |
124+
| Intention | Flag |
125+
|-------------------------------------------------------------------------------------|---------------------------------------|
126+
| Untag all tags that begin with hello in app repository | --filter `"app:^hello.*"` |
127+
| Untag tags that end with world in app repository | --filter `"app:\w*world\b"` |
128+
| Untag tags that include hello-world in their name in app repository | --filter `"app:hello-world"` |
129+
| Untag all tags in repositories ending in /cache | --filter `".*/cache:.*"` |
130+
| Untag all tags in app repository | --filter `"app:.*"` |
131+
| Untag all tags in all repositories | --filter `".*:.*"` |
132+
| Clean only untagged manifests in all repos (with --untagged) | --filter `".*:^$"` |
133+
| Clean only untagged manifests in app repo (with --untagged) | --filter `"app:^$"` |
134+
132135

133136
#### Ago flag
134137

@@ -160,7 +163,7 @@ The following table further explains the functionality of this flag.
160163
| To delete all images that were last modified before 10 minutes ago | --ago 10m |
161164
| To delete all images that were last modified before 1 hour and 15 minutes ago | --ago 1h15m |
162165

163-
The duration should be of the form \[integer\]d\[string\] where the first integer specifies the number of days and the string is in a go style duration (can be omitted)
166+
The duration should be of the form \[integer\]d\[string\] where the first integer specifies the number of days and the string is in a go style duration (can be omitted). The maximimum ago duration is set to 150 years but that will effectively clean nothing up as no images should be that old.
164167

165168
### Optional purge flags
166169

cmd/acr/purge.go

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ var (
6565
// Default settings for regexp2
6666
const (
6767
defaultRegexpMatchTimeoutSeconds int64 = 60
68+
maxAgoDurationYears int = 150 // Maximum duration in years for --ago flag to prevent overflow
6869
)
6970

7071
// purgeParameters defines the parameters that the purge command uses (including the registry name, username and password).
@@ -145,7 +146,7 @@ func newPurgeCmd(rootParams *rootParameters) *cobra.Command {
145146
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")
146147
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")
147148
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")
148-
cmd.Flags().StringVar(&purgeParams.ago, "ago", "", "The tags and untagged manifests 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)")
149+
cmd.Flags().StringVar(&purgeParams.ago, "ago", "", "The tags and untagged manifests 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). Maximum duration is capped at 150 years to prevent overflow")
149150
cmd.Flags().IntVar(&purgeParams.keep, "keep", 0, "Number of latest to-be-deleted tags to keep, use this when you want to keep at least x number of latest tags that could be deleted meeting all other filter criteria")
150151
cmd.Flags().StringArrayVarP(&purgeParams.filters, "filter", "f", nil, "Specify the repository and a regular expression filter for the tag name, if a tag matches the filter and is older than the duration specified in ago it will be deleted. Note: If backtracking is used in the regexp it's possible for the expression to run into an infinite loop. The default timeout is set to 1 minute for evaluation of any filter expression. Use the '--filter-timeout-seconds' option to set a different value.")
151152
cmd.Flags().StringArrayVarP(&purgeParams.configs, "config", "c", nil, "Authentication config paths (e.g. C://Users/docker/config.json)")
@@ -170,16 +171,22 @@ func purge(ctx context.Context,
170171
dryRun bool,
171172
includeLocked bool) (deletedTagsCount int, deletedManifestsCount int, err error) {
172173

174+
// Parse the duration once instead of for every repository
175+
agoDuration, err := parseDuration(tagDeletionSince)
176+
if err != nil {
177+
return 0, 0, err
178+
}
179+
173180
// In order to print a summary of the deleted tags/manifests the counters get updated everytime a repo is purged.
174181
for repoName, tagRegex := range tagFilters {
175-
singleDeletedTagsCount, manifestToTagsCountMap, err := purgeTags(ctx, acrClient, repoParallelism, loginURL, repoName, tagDeletionSince, tagRegex, tagsToKeep, filterTimeout, dryRun, includeLocked)
182+
singleDeletedTagsCount, manifestToTagsCountMap, err := purgeTags(ctx, acrClient, repoParallelism, loginURL, repoName, agoDuration, tagRegex, tagsToKeep, filterTimeout, dryRun, includeLocked)
176183
if err != nil {
177184
return deletedTagsCount, deletedManifestsCount, fmt.Errorf("failed to purge tags: %w", err)
178185
}
179186
singleDeletedManifestsCount := 0
180187
// If the untagged flag is set then also manifests are deleted.
181188
if removeUtaggedManifests {
182-
singleDeletedManifestsCount, err = purgeDanglingManifests(ctx, acrClient, repoParallelism, loginURL, repoName, tagDeletionSince, manifestToTagsCountMap, dryRun, includeLocked)
189+
singleDeletedManifestsCount, err = purgeDanglingManifests(ctx, acrClient, repoParallelism, loginURL, repoName, agoDuration, manifestToTagsCountMap, dryRun, includeLocked)
183190
if err != nil {
184191
return deletedTagsCount, deletedManifestsCount, fmt.Errorf("failed to purge manifests: %w", err)
185192
}
@@ -193,18 +200,14 @@ func purge(ctx context.Context,
193200

194201
}
195202

196-
// purgeTags deletes all tags that are older than the ago value and that match the tagFilter string.
197-
func purgeTags(ctx context.Context, acrClient api.AcrCLIClientInterface, repoParallelism int, loginURL string, repoName string, ago string, tagFilter string, keep int, regexpMatchTimeoutSeconds int64, dryRun bool, includeLocked bool) (int, map[string]int, error) {
203+
// purgeTags deletes all tags that are older than the agoDuration value and that match the tagFilter string.
204+
func purgeTags(ctx context.Context, acrClient api.AcrCLIClientInterface, repoParallelism int, loginURL string, repoName string, agoDuration time.Duration, tagFilter string, keep int, regexpMatchTimeoutSeconds int64, dryRun bool, includeLocked bool) (int, map[string]int, error) {
198205
if dryRun {
199206
fmt.Printf("Would delete tags for repository: %s\n", repoName)
200207
} else {
201208
fmt.Printf("Deleting tags for repository: %s\n", repoName)
202209
}
203210
manifestToTagsCountMap := make(map[string]int) // This map is used to keep track of how many tags would have been deleted per manifest.
204-
agoDuration, err := parseDuration(ago)
205-
if err != nil {
206-
return -1, manifestToTagsCountMap, err
207-
}
208211
timeToCompare := time.Now().UTC()
209212
// Since the parseDuration function returns a negative duration, it is added to the current duration in order to be able to easily compare
210213
// with the LastUpdatedTime attribute a tag has.
@@ -274,14 +277,47 @@ func parseDuration(ago string) (time.Duration, error) {
274277
return time.Duration(0), err
275278
}
276279
}
280+
// Cap at maxAgoDurationYears to prevent overflow
281+
const maxDays = maxAgoDurationYears * 365
282+
originalDays := days
283+
capped := false
284+
if days > maxDays {
285+
days = maxDays
286+
capped = true
287+
fmt.Printf("Warning: ago value exceeds maximum duration of %d years, capping to %d years\n", maxAgoDurationYears, maxAgoDurationYears)
288+
}
277289
// The number of days gets converted to hours.
278290
duration := time.Duration(days) * 24 * time.Hour
279291
if len(durationString) > 0 {
280292
agoDuration, err := time.ParseDuration(durationString)
281293
if err != nil {
282-
return time.Duration(0), err
294+
// Check if it's an overflow error from time.ParseDuration
295+
if strings.Contains(err.Error(), "invalid duration") || strings.Contains(err.Error(), "overflow") {
296+
// If days were already capped, just use that and ignore the overflow portion
297+
if capped {
298+
return (-1 * duration), nil
299+
}
300+
// Cap at max duration and continue
301+
agoDuration = time.Duration(maxDays) * 24 * time.Hour
302+
fmt.Printf("Warning: ago value exceeds maximum duration of %d years, capping to %d years\n", maxAgoDurationYears, maxAgoDurationYears)
303+
} else {
304+
return time.Duration(0), err
305+
}
283306
}
307+
// Cap the additional duration to prevent overflow when adding
308+
maxDuration := time.Duration(maxDays) * 24 * time.Hour
309+
if agoDuration > maxDuration {
310+
agoDuration = maxDuration
311+
if originalDays <= maxDays && !capped {
312+
// Only print warning if we haven't already printed one for days
313+
fmt.Printf("Warning: ago value exceeds maximum duration of %d years, capping to %d years\n", maxAgoDurationYears, maxAgoDurationYears)
314+
}
315+
}
316+
// Make sure the combined duration doesn't exceed max
284317
duration = duration + agoDuration
318+
if duration > maxDuration {
319+
duration = maxDuration
320+
}
285321
}
286322
return (-1 * duration), nil
287323
}
@@ -360,16 +396,12 @@ func getTagsToDelete(ctx context.Context,
360396

361397
// purgeDanglingManifests deletes all manifests that do not have any tags associated with them.
362398
// except the ones that are referenced by a multiarch manifest or that have subject.
363-
func purgeDanglingManifests(ctx context.Context, acrClient api.AcrCLIClientInterface, repoParallelism int, loginURL string, repoName string, tagDeletionSince string, manifestToTagsCountMap map[string]int, dryRun bool, includeLocked bool) (int, error) {
399+
func purgeDanglingManifests(ctx context.Context, acrClient api.AcrCLIClientInterface, repoParallelism int, loginURL string, repoName string, agoDuration time.Duration, manifestToTagsCountMap map[string]int, dryRun bool, includeLocked bool) (int, error) {
364400
if dryRun {
365401
fmt.Printf("Would delete manifests for repository: %s\n", repoName)
366402
} else {
367403
fmt.Printf("Deleting manifests for repository: %s\n", repoName)
368404
}
369-
agoDuration, err := parseDuration(tagDeletionSince)
370-
if err != nil {
371-
return -1, err
372-
}
373405
timeToCompare := time.Now().UTC().Add(agoDuration)
374406
// Contrary to getTagsToDelete, getManifestsToDelete gets all the Manifests at once, this was done because if there is a manifest that has no
375407
// tag but is referenced by a multiarch manifest that has tags then it should not be deleted. Or if a manifest has no tag, but it has subject,

0 commit comments

Comments
 (0)