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"
"strings"
"sync"
"time"
commonFlag "github.com/containers/common/pkg/flag"
"github.com/containers/common/pkg/retry"
@ -47,6 +48,7 @@ func newFilteredTags() *filteredTags {
type tagFilterOptions struct {
BeforeVersion commonFlag.OptionalString
BeforeTime commonFlag.OptionalString
VersionLabel commonFlag.OptionalString
Valid commonFlag.OptionalBool
Invalid commonFlag.OptionalBool
@ -54,13 +56,18 @@ type tagFilterOptions struct {
}
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) {
opts := tagFilterOptions{}
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.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")
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")
@ -190,6 +197,16 @@ func filterDockerTagBySemver(filtered *filteredTags, opts *tagsOptions, threshol
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) {
// Get the user-provided threshold version
// 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
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
// This will be validated later when the comparison takes place
var threshold string
var versionThreshold string
if opts.filterOpts.BeforeVersion.Present() {
threshold = opts.filterOpts.BeforeVersion.Value()
versionThreshold = opts.filterOpts.BeforeVersion.Value()
} else {
// 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
@ -255,13 +272,13 @@ func filterDockerTagsByLabelSemver(ctx context.Context, sys *types.SystemContext
if len(msg) > 0 {
// Log progress in-place
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:
// Print the error and append to the errors slice
if err != nil {
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:
break filterLoop
@ -269,11 +286,11 @@ func filterDockerTagsByLabelSemver(ctx context.Context, sys *types.SystemContext
}
// 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()
}(filteredChan, errChan, doneChan)
// Goroutines for fetching image labels
// Goroutines for fetching image metadata
var fetchWaitGroup = sync.WaitGroup{}
for i := 0; i < len(tags.Tags); i++ {
fetchWaitGroup.Add(1)
@ -319,17 +336,31 @@ func filterDockerTagsByLabelSemver(ctx context.Context, sys *types.SystemContext
return
}
// Get the version label and filter it into the filteredTags struct
versionLabel := opts.filterOpts.VersionLabel.Value()
tagVersion, ok := imgInspect.Labels[versionLabel]
if !ok {
errChan <- fmt.Errorf("For tag %s: version label not found: %s", tag, versionLabel)
return
}
err = filterDockerTagBySemver(filtered, opts, threshold, tag, tagVersion)
if err != nil {
errChan <- fmt.Errorf("Error filtering tags: %w", err)
return
if opts.filterOpts.VersionLabel.Present() {
// If there is a version label threshold, then filter by the version label
versionLabel := opts.filterOpts.VersionLabel.Value()
tagVersion, ok := imgInspect.Labels[versionLabel]
if !ok {
errChan <- fmt.Errorf("For tag %s: version label not found: %s", tag, versionLabel)
return
}
err = filterDockerTagBySemver(filtered, opts, versionThreshold, tag, tagVersion)
if err != nil {
errChan <- fmt.Errorf("For tag %s: error filtering: %w", tag, err)
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
@ -357,7 +388,7 @@ func filterDockerTagsByLabelSemver(ctx context.Context, sys *types.SystemContext
// Return the filtered tags, and an error summary if any errors occurred
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
}
@ -367,8 +398,8 @@ func filterDockerTags(ctx context.Context, sys *types.SystemContext, opts *tagsO
Repository: repositoryName,
Tags: tags,
}
if opts.filterOpts.VersionLabel.Present() {
return filterDockerTagsByLabelSemver(ctx, sys, opts, tagList)
if opts.filterOpts.InspectFilterPresent() {
return filterDockerTagsByImageMetadata(ctx, sys, opts, tagList)
}
return filterDockerTagsByTagSemver(opts, tagList)
}
@ -378,8 +409,8 @@ func listFilteredDockerTags(ctx context.Context, sys *types.SystemContext, opts
if err != nil {
return ``, nil, fmt.Errorf("Error filtering tags by semver: %w", err)
}
if opts.filterOpts.BeforeVersion.Present() {
// Optionally only list tags prior to given semver threshold
if opts.filterOpts.BeforeVersion.Present() || opts.filterOpts.BeforeTime.Present() {
// Optionally only list tags prior to given semver or date threshold
return repositoryName, filtered.ToPrune, nil
} else if opts.filterOpts.Invalid.Present() {
// 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/types"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// Enumerated constant for byte units
@ -76,7 +77,7 @@ func bytesToByteUnit(bytes int64) string {
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,
}
@ -86,11 +87,25 @@ func supportedPruneTransports(joinStr string) string {
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 {
global *globalOptions
image *imageOptions
retryOpts *retry.Options
filterOpts *tagFilterOptions
pruneOpts *pruneUserOptions
}
func (p *pruneOptions) intoTagsOptions() *tagsOptions {
@ -108,12 +123,14 @@ func pruneCmd(global *globalOptions) *cobra.Command {
imageFlags, imageOpts := dockerImageFlags(global, sharedOpts, nil, "", "")
retryFlags, retryOpts := retryFlags()
filterFlags, filterOpts := filterFlags()
pruneFlags, pruneOpts := pruneFlags()
opts := pruneOptions{
global: global,
image: imageOpts,
retryOpts: retryOpts,
filterOpts: filterOpts,
pruneOpts: pruneOpts,
}
cmd := &cobra.Command{
@ -135,6 +152,7 @@ See skopeo-prune(1) section "REPOSITORY NAMES" for the expected format
flags.AddFlagSet(&imageFlags)
flags.AddFlagSet(&retryFlags)
flags.AddFlagSet(&filterFlags)
flags.AddFlagSet(&pruneFlags)
return cmd
}
@ -143,7 +161,7 @@ func displayPrunePrompt() bool {
// Prompt the user whether they would like to proceed to prune
reader := bufio.NewReader(os.Stdin)
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): ")
input, _ := reader.ReadString('\n')
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
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
dedupConfigSizeMap := 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
// 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)
if err != nil {
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()
// Compute the deduplicated size of the images that will be pruned vs kept
pruneSize := getDeduplicatedSizeParallel(ctx, sys, opts, repositoryName, toPrune)
keepSize := getDeduplicatedSizeParallel(ctx, sys, opts, repositoryName, toKeep)
pruneSize := getDeduplicatedSizeParallel(ctx, sys, repositoryName, toPrune)
keepSize := getDeduplicatedSizeParallel(ctx, sys, repositoryName, toKeep)
// Summarize the list
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))
fmt.Fprintln(w, pruneDisp)
fmt.Fprintln(w, keepDisp)
fmt.Fprintln(w, "")
w.Flush()
return nil // Success
}
// 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
totalTags := len(tags)
tagsPruned := 0
@ -577,9 +596,9 @@ func getFilteredDockerTags(ctx context.Context, sys *types.SystemContext, opts *
}
// 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
filteredTags, err := getFilteredDockerTags(ctx, sys, opts, userInput)
filteredTags, err := getFilteredDockerTags(ctx, sys, opts.intoTagsOptions(), userInput)
if err != nil {
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
// TODO: Error check - determine if errors occurred during size calculation
err = displayPruneSummary(ctx, sys, opts, userInput, toPrune, toKeep)
if err != nil {
return fmt.Errorf("Error displaying prune summary: %w", err)
if !opts.pruneOpts.SkipSummary {
err = displayPruneSummary(ctx, sys, userInput, toPrune, toKeep)
if err != nil {
return fmt.Errorf("Error displaying prune summary: %w", err)
}
}
// Display the prune prompt
accepted := displayPrunePrompt()
if !accepted{
return nil // User decided not to prune
if !opts.pruneOpts.NonInteractive {
accepted := displayPrunePrompt()
if !accepted{
return nil // User decided not to prune
}
}
// Prune the docker tags
err = pruneDockerTagsParallel(ctx, sys, opts, userInput, toPrune)
err = pruneDockerTagsParallel(ctx, sys, userInput, toPrune)
if err != nil {
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 {
err = val(ctx, sys, opts.intoTagsOptions(), args[0])
err = val(ctx, sys, opts, args[0])
if err != nil {
return err
}
@ -646,5 +669,3 @@ func (opts *pruneOptions) run(args []string, stdout io.Writer) (retErr error) {
return nil // Success
}
// TODO: Prune-many command