feat(prune): filter by date, skip summary and noninteractive opts

Signed-off-by: Ethan Balcik <ebalcik71@gmail.com>
This commit is contained in:
whatsacomputertho 2025-07-16 20:36:12 -04:00 committed by Ethan Balcik
parent 3585b8de8f
commit 10b95819ba
2 changed files with 96 additions and 44 deletions

View File

@ -11,6 +11,7 @@ import (
"slices" "slices"
"strings" "strings"
"sync" "sync"
"time"
commonFlag "github.com/containers/common/pkg/flag" commonFlag "github.com/containers/common/pkg/flag"
"github.com/containers/common/pkg/retry" "github.com/containers/common/pkg/retry"
@ -47,6 +48,7 @@ func newFilteredTags() *filteredTags {
type tagFilterOptions struct { type tagFilterOptions struct {
BeforeVersion commonFlag.OptionalString BeforeVersion commonFlag.OptionalString
BeforeTime commonFlag.OptionalString
VersionLabel commonFlag.OptionalString VersionLabel commonFlag.OptionalString
Valid commonFlag.OptionalBool Valid commonFlag.OptionalBool
Invalid commonFlag.OptionalBool Invalid commonFlag.OptionalBool
@ -54,13 +56,18 @@ type tagFilterOptions struct {
} }
func (opts *tagFilterOptions) FilterPresent() bool { func (opts *tagFilterOptions) FilterPresent() bool {
return opts.BeforeVersion.Present() || opts.Valid.Present() || opts.Invalid.Present() return opts.BeforeVersion.Present() || opts.Valid.Present() || opts.Invalid.Present() || opts.BeforeTime.Present()
}
func (opts *tagFilterOptions) InspectFilterPresent() bool {
return (opts.BeforeVersion.Present() && opts.VersionLabel.Present()) || opts.BeforeTime.Present()
} }
func filterFlags() (pflag.FlagSet, *tagFilterOptions) { func filterFlags() (pflag.FlagSet, *tagFilterOptions) {
opts := tagFilterOptions{} opts := tagFilterOptions{}
fs := pflag.FlagSet{} fs := pflag.FlagSet{}
fs.Var(commonFlag.NewOptionalStringValue(&opts.BeforeVersion), "before-version", "A version threshold prior to which to list tags") fs.Var(commonFlag.NewOptionalStringValue(&opts.BeforeVersion), "before-version", "A version threshold prior to which to list tags")
fs.Var(commonFlag.NewOptionalStringValue(&opts.BeforeTime), "before-time", "A date threshold prior to which to list tags")
fs.Var(commonFlag.NewOptionalStringValue(&opts.VersionLabel), "version-label", "A label from which to derive the version for each tag") fs.Var(commonFlag.NewOptionalStringValue(&opts.VersionLabel), "version-label", "A label from which to derive the version for each tag")
commonFlag.OptionalBoolFlag(&fs, &opts.Valid, "valid", "Whether to list only tags with valid semver") commonFlag.OptionalBoolFlag(&fs, &opts.Valid, "valid", "Whether to list only tags with valid semver")
commonFlag.OptionalBoolFlag(&fs, &opts.Invalid, "invalid", "Whether to list only tags with invalid semver") commonFlag.OptionalBoolFlag(&fs, &opts.Invalid, "invalid", "Whether to list only tags with invalid semver")
@ -190,6 +197,16 @@ func filterDockerTagBySemver(filtered *filteredTags, opts *tagsOptions, threshol
return nil return nil
} }
func filterDockerTagByDate(filtered *filteredTags, opts *tagsOptions, timeThreshold time.Time, tag string, created time.Time) (error) {
// Compare the created date against the threshold date
if created.Before(timeThreshold) {
filtered.ToPrune = append(filtered.ToPrune, tag)
} else {
filtered.ToKeep = append(filtered.ToKeep, tag)
}
return nil
}
func filterDockerTagsByTagSemver(opts *tagsOptions, tags *tagListOutput) (*filteredTags, error) { func filterDockerTagsByTagSemver(opts *tagsOptions, tags *tagListOutput) (*filteredTags, error) {
// Get the user-provided threshold version // Get the user-provided threshold version
// This will be validated later when the comparison takes place // This will be validated later when the comparison takes place
@ -213,15 +230,15 @@ func filterDockerTagsByTagSemver(opts *tagsOptions, tags *tagListOutput) (*filte
} }
// Use goroutines to parallelize & display progress continuously // Use goroutines to parallelize & display progress continuously
func filterDockerTagsByLabelSemver(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, tags *tagListOutput) (*filteredTags, error) { func filterDockerTagsByImageMetadata(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, tags *tagListOutput) (*filteredTags, error) {
// Get the user-provided threshold version // Get the user-provided threshold version
// This will be validated later when the comparison takes place // This will be validated later when the comparison takes place
var threshold string var versionThreshold string
if opts.filterOpts.BeforeVersion.Present() { if opts.filterOpts.BeforeVersion.Present() {
threshold = opts.filterOpts.BeforeVersion.Value() versionThreshold = opts.filterOpts.BeforeVersion.Value()
} else { } else {
// Set as an arbitrary valid version since this isn't going to affect output // Set as an arbitrary valid version since this isn't going to affect output
threshold = "v0.1.0" versionThreshold = "v0.1.0"
} }
// Initialize a zeroed filteredTags struct to return // Initialize a zeroed filteredTags struct to return
@ -255,13 +272,13 @@ func filterDockerTagsByLabelSemver(ctx context.Context, sys *types.SystemContext
if len(msg) > 0 { if len(msg) > 0 {
// Log progress in-place // Log progress in-place
tagsFiltered += 1 tagsFiltered += 1
fmt.Printf("\rfetching image labels\t%d / %d", tagsFiltered, totalTags) fmt.Fprintf(os.Stderr, "\rinspecting image tags\t%d / %d", tagsFiltered, totalTags)
} }
case err := <-errChan: case err := <-errChan:
// Print the error and append to the errors slice // Print the error and append to the errors slice
if err != nil { if err != nil {
numErrors += 1 numErrors += 1
fmt.Fprintf(os.Stderr, "\nerror while fetching image labels %s\n", err) fmt.Fprintf(os.Stderr, "\nerror while inspecting image tags %s\n", err)
} }
case <-doneChan: case <-doneChan:
break filterLoop break filterLoop
@ -269,11 +286,11 @@ func filterDockerTagsByLabelSemver(ctx context.Context, sys *types.SystemContext
} }
// Log completion in-place and close the goroutine // Log completion in-place and close the goroutine
fmt.Printf("\rfetching image labels\t%d / %d (done)\n", tagsFiltered, totalTags) fmt.Fprintf(os.Stderr, "\rinspecting image tags\t%d / %d (done)\n", tagsFiltered, totalTags)
readWaitGroup.Done() readWaitGroup.Done()
}(filteredChan, errChan, doneChan) }(filteredChan, errChan, doneChan)
// Goroutines for fetching image labels // Goroutines for fetching image metadata
var fetchWaitGroup = sync.WaitGroup{} var fetchWaitGroup = sync.WaitGroup{}
for i := 0; i < len(tags.Tags); i++ { for i := 0; i < len(tags.Tags); i++ {
fetchWaitGroup.Add(1) fetchWaitGroup.Add(1)
@ -319,18 +336,32 @@ func filterDockerTagsByLabelSemver(ctx context.Context, sys *types.SystemContext
return return
} }
// Get the version label and filter it into the filteredTags struct if opts.filterOpts.VersionLabel.Present() {
// If there is a version label threshold, then filter by the version label
versionLabel := opts.filterOpts.VersionLabel.Value() versionLabel := opts.filterOpts.VersionLabel.Value()
tagVersion, ok := imgInspect.Labels[versionLabel] tagVersion, ok := imgInspect.Labels[versionLabel]
if !ok { if !ok {
errChan <- fmt.Errorf("For tag %s: version label not found: %s", tag, versionLabel) errChan <- fmt.Errorf("For tag %s: version label not found: %s", tag, versionLabel)
return return
} }
err = filterDockerTagBySemver(filtered, opts, threshold, tag, tagVersion) err = filterDockerTagBySemver(filtered, opts, versionThreshold, tag, tagVersion)
if err != nil { if err != nil {
errChan <- fmt.Errorf("Error filtering tags: %w", err) errChan <- fmt.Errorf("For tag %s: error filtering: %w", tag, err)
return return
} }
} else {
// If there is a time threshold, then filter by the created date
timeThreshold, err := time.Parse(time.RFC3339, opts.filterOpts.BeforeTime.Value())
if err != nil {
errChan <- fmt.Errorf("Error parsing time threshold for filtering: %w", err)
return
}
err = filterDockerTagByDate(filtered, opts, timeThreshold, tag, *imgInspect.Created)
if err != nil {
errChan <- fmt.Errorf("For tag %s: error filtering: %w", tag, err)
return
}
}
// If successful, then signal completion // If successful, then signal completion
filteredChan <- "done" filteredChan <- "done"
@ -357,7 +388,7 @@ func filterDockerTagsByLabelSemver(ctx context.Context, sys *types.SystemContext
// Return the filtered tags, and an error summary if any errors occurred // Return the filtered tags, and an error summary if any errors occurred
if numErrors > 0 { if numErrors > 0 {
return filtered, fmt.Errorf("Encountered %d errors while filtering tags by label semver", numErrors) return filtered, fmt.Errorf("Encountered %d errors while filtering tags by inspect metadata", numErrors)
} }
return filtered, nil return filtered, nil
} }
@ -367,8 +398,8 @@ func filterDockerTags(ctx context.Context, sys *types.SystemContext, opts *tagsO
Repository: repositoryName, Repository: repositoryName,
Tags: tags, Tags: tags,
} }
if opts.filterOpts.VersionLabel.Present() { if opts.filterOpts.InspectFilterPresent() {
return filterDockerTagsByLabelSemver(ctx, sys, opts, tagList) return filterDockerTagsByImageMetadata(ctx, sys, opts, tagList)
} }
return filterDockerTagsByTagSemver(opts, tagList) return filterDockerTagsByTagSemver(opts, tagList)
} }
@ -378,8 +409,8 @@ func listFilteredDockerTags(ctx context.Context, sys *types.SystemContext, opts
if err != nil { if err != nil {
return ``, nil, fmt.Errorf("Error filtering tags by semver: %w", err) return ``, nil, fmt.Errorf("Error filtering tags by semver: %w", err)
} }
if opts.filterOpts.BeforeVersion.Present() { if opts.filterOpts.BeforeVersion.Present() || opts.filterOpts.BeforeTime.Present() {
// Optionally only list tags prior to given semver threshold // Optionally only list tags prior to given semver or date threshold
return repositoryName, filtered.ToPrune, nil return repositoryName, filtered.ToPrune, nil
} else if opts.filterOpts.Invalid.Present() { } else if opts.filterOpts.Invalid.Present() {
// Optionally only list tags with invalid semver // Optionally only list tags with invalid semver

View File

@ -20,6 +20,7 @@ import (
"github.com/containers/image/v5/transports/alltransports" "github.com/containers/image/v5/transports/alltransports"
"github.com/containers/image/v5/types" "github.com/containers/image/v5/types"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
// Enumerated constant for byte units // Enumerated constant for byte units
@ -76,7 +77,7 @@ func bytesToByteUnit(bytes int64) string {
return fmt.Sprintf("%.2f %s", unitDec, unitSuf) return fmt.Sprintf("%.2f %s", unitDec, unitSuf)
} }
var pruneTransportHandlers = map[string]func(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, userInput string) error { var pruneTransportHandlers = map[string]func(ctx context.Context, sys *types.SystemContext, opts *pruneOptions, userInput string) error {
docker.Transport.Name(): pruneDockerTags, docker.Transport.Name(): pruneDockerTags,
} }
@ -86,11 +87,25 @@ func supportedPruneTransports(joinStr string) string {
return strings.Join(res, joinStr) return strings.Join(res, joinStr)
} }
type pruneUserOptions struct {
SkipSummary bool
NonInteractive bool
}
func pruneFlags() (pflag.FlagSet, *pruneUserOptions) {
opts := pruneUserOptions{}
fs := pflag.FlagSet{}
fs.BoolVarP(&opts.SkipSummary, "skip-summary", "s", false, "Skip computing the prune summary of freed storage space")
fs.BoolVarP(&opts.NonInteractive, "non-interactive", "y", false, "Do not display an interactive prompt for the user to confirm before beginning puning")
return fs, &opts
}
type pruneOptions struct { type pruneOptions struct {
global *globalOptions global *globalOptions
image *imageOptions image *imageOptions
retryOpts *retry.Options retryOpts *retry.Options
filterOpts *tagFilterOptions filterOpts *tagFilterOptions
pruneOpts *pruneUserOptions
} }
func (p *pruneOptions) intoTagsOptions() *tagsOptions { func (p *pruneOptions) intoTagsOptions() *tagsOptions {
@ -108,12 +123,14 @@ func pruneCmd(global *globalOptions) *cobra.Command {
imageFlags, imageOpts := dockerImageFlags(global, sharedOpts, nil, "", "") imageFlags, imageOpts := dockerImageFlags(global, sharedOpts, nil, "", "")
retryFlags, retryOpts := retryFlags() retryFlags, retryOpts := retryFlags()
filterFlags, filterOpts := filterFlags() filterFlags, filterOpts := filterFlags()
pruneFlags, pruneOpts := pruneFlags()
opts := pruneOptions{ opts := pruneOptions{
global: global, global: global,
image: imageOpts, image: imageOpts,
retryOpts: retryOpts, retryOpts: retryOpts,
filterOpts: filterOpts, filterOpts: filterOpts,
pruneOpts: pruneOpts,
} }
cmd := &cobra.Command{ cmd := &cobra.Command{
@ -135,6 +152,7 @@ See skopeo-prune(1) section "REPOSITORY NAMES" for the expected format
flags.AddFlagSet(&imageFlags) flags.AddFlagSet(&imageFlags)
flags.AddFlagSet(&retryFlags) flags.AddFlagSet(&retryFlags)
flags.AddFlagSet(&filterFlags) flags.AddFlagSet(&filterFlags)
flags.AddFlagSet(&pruneFlags)
return cmd return cmd
} }
@ -143,7 +161,7 @@ func displayPrunePrompt() bool {
// Prompt the user whether they would like to proceed to prune // Prompt the user whether they would like to proceed to prune
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
for { for {
fmt.Println("\nwarning: continuing to prune will lead to irreversible data loss") fmt.Println("warning: continuing to prune will lead to irreversible data loss")
fmt.Print("continue to prune? (y/n): ") fmt.Print("continue to prune? (y/n): ")
input, _ := reader.ReadString('\n') input, _ := reader.ReadString('\n')
input = strings.TrimSpace(input) input = strings.TrimSpace(input)
@ -312,7 +330,7 @@ func getSizeMaps(ctx context.Context, sys *types.SystemContext, url string) (map
} }
// Function that computes the deduplicated size of a slice of image tags // Function that computes the deduplicated size of a slice of image tags
func getDeduplicatedSizeParallel(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, repositoryName string, tags []string) int64 { func getDeduplicatedSizeParallel(ctx context.Context, sys *types.SystemContext, repositoryName string, tags []string) int64 {
// Get the config & layer size maps // Get the config & layer size maps
dedupConfigSizeMap := make(map[string]int64) dedupConfigSizeMap := make(map[string]int64)
dedupLayerSizeMap := make(map[string]int64) dedupLayerSizeMap := make(map[string]int64)
@ -428,7 +446,7 @@ func getDeduplicatedSizeParallel(ctx context.Context, sys *types.SystemContext,
// Function that displays the prune summary of size freed in pruning // Function that displays the prune summary of size freed in pruning
// TODO: Determine if errors occurred during size calculation // TODO: Determine if errors occurred during size calculation
func displayPruneSummary(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, userInput string, toPrune []string, toKeep []string) error { func displayPruneSummary(ctx context.Context, sys *types.SystemContext, userInput string, toPrune []string, toKeep []string) error {
imgRef, err := parseDockerRepositoryReference(userInput) imgRef, err := parseDockerRepositoryReference(userInput)
if err != nil { if err != nil {
return fmt.Errorf("Error parsing image reference: %w", err) return fmt.Errorf("Error parsing image reference: %w", err)
@ -436,8 +454,8 @@ func displayPruneSummary(ctx context.Context, sys *types.SystemContext, opts *ta
repositoryName := imgRef.DockerReference().Name() repositoryName := imgRef.DockerReference().Name()
// Compute the deduplicated size of the images that will be pruned vs kept // Compute the deduplicated size of the images that will be pruned vs kept
pruneSize := getDeduplicatedSizeParallel(ctx, sys, opts, repositoryName, toPrune) pruneSize := getDeduplicatedSizeParallel(ctx, sys, repositoryName, toPrune)
keepSize := getDeduplicatedSizeParallel(ctx, sys, opts, repositoryName, toKeep) keepSize := getDeduplicatedSizeParallel(ctx, sys, repositoryName, toKeep)
// Summarize the list // Summarize the list
fmt.Println("") fmt.Println("")
@ -447,12 +465,13 @@ func displayPruneSummary(ctx context.Context, sys *types.SystemContext, opts *ta
keepDisp := fmt.Sprintf("Keep\t%d\t%s", len(toKeep), bytesToByteUnit(keepSize)) keepDisp := fmt.Sprintf("Keep\t%d\t%s", len(toKeep), bytesToByteUnit(keepSize))
fmt.Fprintln(w, pruneDisp) fmt.Fprintln(w, pruneDisp)
fmt.Fprintln(w, keepDisp) fmt.Fprintln(w, keepDisp)
fmt.Fprintln(w, "")
w.Flush() w.Flush()
return nil // Success return nil // Success
} }
// Function that prunes the tags identified for pruning and displays progress // Function that prunes the tags identified for pruning and displays progress
func pruneDockerTagsParallel(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, repositoryName string, tags []string) error { func pruneDockerTagsParallel(ctx context.Context, sys *types.SystemContext, repositoryName string, tags []string) error {
// Variable tracking how many tags have been pruned // Variable tracking how many tags have been pruned
totalTags := len(tags) totalTags := len(tags)
tagsPruned := 0 tagsPruned := 0
@ -577,9 +596,9 @@ func getFilteredDockerTags(ctx context.Context, sys *types.SystemContext, opts *
} }
// Function that prunes docker tags // Function that prunes docker tags
func pruneDockerTags(ctx context.Context, sys *types.SystemContext, opts *tagsOptions, userInput string) error { func pruneDockerTags(ctx context.Context, sys *types.SystemContext, opts *pruneOptions, userInput string) error {
// Get the filtered docker tags for the given repository // Get the filtered docker tags for the given repository
filteredTags, err := getFilteredDockerTags(ctx, sys, opts, userInput) filteredTags, err := getFilteredDockerTags(ctx, sys, opts.intoTagsOptions(), userInput)
if err != nil { if err != nil {
return fmt.Errorf("Error getting filtered docker tags: %w", err) return fmt.Errorf("Error getting filtered docker tags: %w", err)
} }
@ -597,19 +616,23 @@ func pruneDockerTags(ctx context.Context, sys *types.SystemContext, opts *tagsOp
// Display the prune summary // Display the prune summary
// TODO: Error check - determine if errors occurred during size calculation // TODO: Error check - determine if errors occurred during size calculation
err = displayPruneSummary(ctx, sys, opts, userInput, toPrune, toKeep) if !opts.pruneOpts.SkipSummary {
err = displayPruneSummary(ctx, sys, userInput, toPrune, toKeep)
if err != nil { if err != nil {
return fmt.Errorf("Error displaying prune summary: %w", err) return fmt.Errorf("Error displaying prune summary: %w", err)
} }
}
// Display the prune prompt // Display the prune prompt
if !opts.pruneOpts.NonInteractive {
accepted := displayPrunePrompt() accepted := displayPrunePrompt()
if !accepted{ if !accepted{
return nil // User decided not to prune return nil // User decided not to prune
} }
}
// Prune the docker tags // Prune the docker tags
err = pruneDockerTagsParallel(ctx, sys, opts, userInput, toPrune) err = pruneDockerTagsParallel(ctx, sys, userInput, toPrune)
if err != nil { if err != nil {
return fmt.Errorf("Error pruning tags: %w", err) return fmt.Errorf("Error pruning tags: %w", err)
} }
@ -635,7 +658,7 @@ func (opts *pruneOptions) run(args []string, stdout io.Writer) (retErr error) {
} }
if val, ok := pruneTransportHandlers[transport.Name()]; ok { if val, ok := pruneTransportHandlers[transport.Name()]; ok {
err = val(ctx, sys, opts.intoTagsOptions(), args[0]) err = val(ctx, sys, opts, args[0])
if err != nil { if err != nil {
return err return err
} }
@ -646,5 +669,3 @@ func (opts *pruneOptions) run(args []string, stdout io.Writer) (retErr error) {
return nil // Success return nil // Success
} }
// TODO: Prune-many command